Hello from MCP server
<template>
<BaseLayout title="Editor">
<ion-grid :fixed="true">
<ion-row>
<ion-col>
<h2 v-if="isIdMode">Record Details: {{ searchId }}</h2>
<h2 v-else>Database Browser</h2>
</ion-col>
</ion-row>
<!-- Show ID search mode -->
<div v-if="isIdMode">
<ion-row>
<ion-col>
<p>Showing record with ID: <strong>{{ searchId }}</strong></p>
<ion-button fill="outline" @click="goBackToBrowse">
<ion-icon :icon="arrowBack" slot="start"></ion-icon>
Back to Browse
</ion-button>
<ion-button v-if="!isEditMode" fill="solid" color="primary" @click="startEditMode">
<ion-icon :icon="pencil" slot="start"></ion-icon>
Edit
</ion-button>
<ion-button v-if="!isEditMode" fill="outline" color="danger" @click="confirmDelete">
<ion-icon :icon="trash" slot="start"></ion-icon>
Delete
</ion-button>
<ion-button v-if="isEditMode" fill="solid" color="success" @click="saveChanges">
<ion-icon :icon="checkmark" slot="start"></ion-icon>
Save Changes
</ion-button>
<ion-button v-if="isEditMode" fill="outline" color="medium" @click="cancelEdit">
<ion-icon :icon="close" slot="start"></ion-icon>
Cancel
</ion-button>
</ion-col>
</ion-row>
</div>
<!-- Show create mode -->
<div v-if="isCreateMode">
<ion-row>
<ion-col>
<h2>Create New Record</h2>
<ion-button fill="outline" @click="goBackToBrowse">
<ion-icon :icon="arrowBack" slot="start"></ion-icon>
Back to Browse
</ion-button>
</ion-col>
</ion-row>
<ion-row v-if="!createSelectedCollection">
<ion-col>
<ion-item fill="outline">
<ion-label position="stacked">Select Collection Type</ion-label>
<select v-model="createSelectedCollection" @change="onCreateCollectionSelect" class="table-select">
<option value="">Choose collection...</option>
<option v-for="table in allowedTables" :key="table" :value="table">
{{ table }}
</option>
</select>
</ion-item>
</ion-col>
</ion-row>
<ion-row v-else>
<ion-col>
<ion-card>
<ion-card-header>
<ion-card-title>New {{ createSelectedCollection }} Record</ion-card-title>
</ion-card-header>
<ion-card-content>
<div v-for="field in createFields" :key="field.name" class="field-row">
<strong>{{ field.name }}:</strong>
<div class="field-value">
<!-- Regular fields -->
<div v-if="!field.isRelation">
<ion-textarea
v-model="createData[field.name]"
:placeholder="`Enter ${field.name}...`"
fill="outline"
:rows="2"
:auto-grow="true"
class="edit-input"
:type="field.type === 'number' ? 'number' : 'text'"
></ion-textarea>
<small v-if="field.isCurrency" class="currency-hint">
💰 Entering in {{ preferences.currency }} (will be converted to base currency)
</small>
</div>
<!-- Relation fields -->
<div v-else class="relation-edit">
<!-- Single relation selector -->
<div v-if="field.relationType === 'single'">
<select
v-model="editedRelations[field.name]"
class="table-select"
@change="onSingleRelationChange(field.name, $event)"
>
<option :value="null">None</option>
<option
v-for="option in availableRelations[field.name]"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
<!-- Multi relation selector -->
<div v-else>
<!-- Current relations as removable chips -->
<div v-if="editedRelations[field.name]?.length > 0" class="current-relations">
<ion-chip
v-for="relationId in editedRelations[field.name]"
:key="relationId"
@click="removeRelation(field.name, relationId)"
class="relation-chip removable"
>
{{ getRelationLabel(field.name, relationId) }}
<ion-icon :icon="close" class="remove-icon"></ion-icon>
</ion-chip>
</div>
<!-- Add new relations button -->
<div class="add-relations">
<ion-button
fill="outline"
size="small"
@click="openRelationSelector(field.name)"
>
Add {{ field.name }}...
</ion-button>
</div>
</div>
</div>
</div>
</div>
<div class="create-actions">
<ion-button fill="outline" @click="cancelCreate">
<ion-icon :icon="close" slot="start"></ion-icon>
Cancel
</ion-button>
<ion-button color="primary" @click="saveNewRecord">
<ion-icon :icon="checkmark" slot="start"></ion-icon>
Create Record
</ion-button>
</div>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</div>
<!-- Show table browser mode -->
<div v-else-if="!isIdMode">
<ion-row class="lookup-row">
<ion-col size="auto">
<ion-input
v-model="lookupId"
placeholder="Enter record ID"
class="lookup-input"
></ion-input>
</ion-col>
<ion-col size="auto">
<ion-button color="medium" @click="handleLookup">
Look up
</ion-button>
</ion-col>
</ion-row>
<ion-row>
<ion-col>
<ion-button color="success" @click="navigateToCreate">
<ion-icon :icon="add" slot="start"></ion-icon>
Create New Record
</ion-button>
<ion-button color="primary" @click="createNewBook">
<ion-icon :icon="add" slot="start"></ion-icon>
Create New Book
</ion-button>
<ion-button
:fill="showChangeHistory ? 'solid' : 'outline'"
:color="showChangeHistory ? 'tertiary' : 'tertiary'"
@click="toggleChangeHistory"
>
<ion-icon :icon="listOutline" slot="start"></ion-icon>
{{ showChangeHistory ? 'Hide Changes' : 'Review Changes' }}
</ion-button>
</ion-col>
</ion-row>
<ion-row>
<ion-col>
<ion-button
:fill="showTechHandbook ? 'solid' : 'outline'"
color="secondary"
@click="browseTechHandbook"
>
{{ showTechHandbook ? 'Hide Tech Handbook' : 'Browse Tech Handbook' }}
</ion-button>
</ion-col>
</ion-row>
<!-- Tech Handbook Browser -->
<div v-if="showTechHandbook" class="tech-handbook-section">
<ion-card>
<ion-card-header>
<ion-card-title>Tech Handbook Browser</ion-card-title>
</ion-card-header>
<ion-card-content>
<div v-if="techHandbookLoading" class="loading-state">
<ion-spinner name="crescent"></ion-spinner>
<p>Loading tech handbook data...</p>
</div>
<div v-else-if="techHandbookError" class="error-state">
<p>{{ techHandbookError }}</p>
</div>
<div v-else-if="techHandbookData.length === 0" class="empty-state">
<p>No tech handbook entries found.</p>
</div>
<div v-else>
<p class="tech-handbook-summary">
<strong>{{ techHandbookData.length }}</strong> offers with tech handbook entries
</p>
<div v-for="offer in techHandbookData" :key="offer.offerId" class="tech-handbook-offer">
<h3 class="offer-title">
<a :href="'/editor/offers/' + offer.offerId" target="_blank">{{ offer.offerName }}</a>
</h3>
<ul class="tech-handbook-items">
<li v-for="item in offer.items" :key="item.id" class="tech-handbook-item">
{{ item.content }}
</li>
</ul>
</div>
</div>
</ion-card-content>
</ion-card>
</div>
<ion-row v-if="!showChangeHistory">
<ion-col size="6">
<ion-item fill="outline">
<ion-label position="stacked">SELECT * FROM</ion-label>
<ion-input
v-model="tableName"
placeholder="Enter table name..."
@keyup.enter="executeQuery"
></ion-input>
</ion-item>
</ion-col>
<ion-col size="6">
<ion-item fill="outline">
<ion-label position="stacked">Or select from allowed tables:</ion-label>
<select v-model="selectedTable" @change="onTableSelect" class="table-select">
<option value="">Choose table...</option>
<option v-for="table in allowedTables" :key="table" :value="table">
{{ table }}
</option>
</select>
</ion-item>
</ion-col>
</ion-row>
<ion-row v-if="!showChangeHistory">
<ion-col>
<ion-button @click="executeQuery" :disabled="!tableName.trim()">
Execute Query
</ion-button>
<ion-button fill="clear" @click="clearResults">
Clear
</ion-button>
</ion-col>
</ion-row>
<!-- Change History View -->
<div v-if="showChangeHistory" class="change-history-section">
<ion-row>
<ion-col>
<ion-card>
<ion-card-header>
<ion-card-title>Change History</ion-card-title>
<ion-segment v-model="changeHistoryViewMode" class="view-mode-segment">
<ion-segment-button value="friendly">
<ion-label>Friendly View</ion-label>
</ion-segment-button>
<ion-segment-button value="json">
<ion-label>JSON Editor</ion-label>
</ion-segment-button>
</ion-segment>
</ion-card-header>
<ion-card-content>
<!-- Friendly View -->
<div v-if="changeHistoryViewMode === 'friendly'" class="friendly-view">
<div v-if="changeMessages.length === 0" class="empty-state">
<p>No change messages found</p>
</div>
<ion-card v-for="msg in changeMessages" :key="msg.id" class="change-message-card">
<ion-card-header>
<div class="change-header-row">
<ion-card-subtitle>
{{ formatDate(msg.created) }}
</ion-card-subtitle>
<ion-chip :color="getStatusColor(msg.status)" size="small">{{ msg.status }}</ion-chip>
</div>
<div class="change-meta">
<span class="meta-item">Book: {{ getBookName(msg.book) }}</span>
</div>
</ion-card-header>
<ion-card-content>
<div v-if="msg.error_message" class="error-message">
{{ msg.error_message }}
</div>
<div class="changeset-details">
<div v-for="(change, i) in msg.changeset" :key="i" class="change-item">
<div class="change-operation" :class="change.operation">
<span class="op-badge">{{ change.operation.toUpperCase() }}</span>
<span class="collection-name">{{ change.collection }}</span>
<span v-if="change.data?.refId" class="ref-id">{{ change.data.refId }}</span>
</div>
<div class="change-data-preview">
<div v-for="(value, key) in getPreviewData(change.data)" :key="key" class="data-preview-field">
<span class="field-key">{{ key }}:</span>
<span class="field-value-preview">{{ truncateValue(value) }}</span>
</div>
</div>
<ion-button fill="clear" size="small" @click="expandChange(msg, i)">
View Details
</ion-button>
</div>
</div>
</ion-card-content>
</ion-card>
</div>
<!-- JSON Editor View -->
<div v-else class="json-view">
<div class="json-controls">
<ion-button
v-if="superSecretMode"
color="dark"
size="small"
@click="showDeleteDbConfirmDialog = true"
>
<ion-icon :icon="skullOutline" slot="start"></ion-icon>
Delete Database
</ion-button>
<ion-button
v-if="superSecretMode"
color="medium"
size="small"
@click="openDbPicker"
>
<ion-icon :icon="folderOpenOutline" slot="start"></ion-icon>
DB File Picker
</ion-button>
<ion-button
v-if="superSecretMode"
color="success"
size="small"
@click="saveAsDefaultDb"
>
<ion-icon :icon="saveOutline" slot="start"></ion-icon>
Save as Default
</ion-button>
<ion-button
v-if="superSecretMode"
color="tertiary"
size="small"
@click="uploadDefaultDbToServer"
>
<ion-icon :icon="cloudUploadOutline" slot="start"></ion-icon>
Upload Default DB
</ion-button>
<ion-button
v-if="superSecretMode"
color="secondary"
size="small"
@click="downloadDefaultDbFromServer"
>
<ion-icon :icon="cloudDownloadOutline" slot="start"></ion-icon>
Download Default
</ion-button>
<ion-button color="danger" size="small" @click="showReplayConfirmDialog = true">
<ion-icon :icon="refreshOutline" slot="start"></ion-icon>
Replay All Changes
</ion-button>
<ion-button fill="outline" size="small" @click="copyAllJson">
<ion-icon :icon="copyOutline" slot="start"></ion-icon>
Copy All
</ion-button>
<ion-button color="warning" size="small" @click="showCombineChangesDialog">
<ion-icon :icon="gitMergeOutline" slot="start"></ion-icon>
Combine Changes
</ion-button>
<ion-button color="success" size="small" @click="pushChangesToServer">
<ion-icon :icon="cloudUploadOutline" slot="start"></ion-icon>
Push Changes
</ion-button>
<ion-button color="primary" size="small" @click="downloadAndViewAllChanges">
<ion-icon :icon="cloudDownloadOutline" slot="start"></ion-icon>
Download All Changes
</ion-button>
</div>
<div v-if="changeMessages.length === 0" class="empty-state">
<p>No change messages found</p>
</div>
<div v-for="(msg, msgIndex) in changeMessages" :key="msg.id" class="json-message-block">
<div class="json-message-header">
<span class="json-date">{{ formatDate(msg.created) }}</span>
<ion-chip
v-if="!jsonEdited[msgIndex]"
:color="getStatusColor(msg.status)"
size="small"
>{{ msg.status }}</ion-chip>
<ion-button
v-else
size="small"
color="warning"
@click="saveChangeset(msgIndex)"
:disabled="!!jsonErrors[msgIndex]"
>
Save
</ion-button>
<ion-button fill="clear" size="small" @click="copyMessageJson(msg)">
<ion-icon :icon="copyOutline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" color="danger" @click="deleteChangesetLocally(msg.id)">
<ion-icon :icon="trash"></ion-icon>
</ion-button>
</div>
<textarea
class="json-editor"
:value="formatJsonForEdit(msg)"
@input="(e) => updateChangesetJson(msgIndex, e)"
spellcheck="false"
></textarea>
<div v-if="jsonErrors[msgIndex]" class="json-error">
{{ jsonErrors[msgIndex] }}
</div>
</div>
</div>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</div>
</div>
<ion-row v-if="loading">
<ion-col>
<ion-spinner></ion-spinner>
<p>Executing query...</p>
</ion-col>
</ion-row>
<ion-row v-if="error">
<ion-col>
<ion-card color="danger">
<ion-card-header>
<ion-card-title>Error</ion-card-title>
</ion-card-header>
<ion-card-content>
<pre>{{ error }}</pre>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
<ion-row v-if="results">
<ion-col>
<ion-card>
<ion-card-header>
<ion-card-title>Results from: {{ lastQuery }}</ion-card-title>
<ion-card-subtitle v-if="results.values">
<span v-if="!isIdMode && resultsFilter.trim()">
{{ filteredResults.length }} of {{ results.values.length }} row(s) shown
</span>
<span v-else>
{{ results.values.length }} row(s) returned
</span>
</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<!-- Filter input - only show in browse mode, not in ID/edit mode -->
<div v-if="!isIdMode && results.values && results.values.length > 0" class="filter-container">
<ion-item fill="outline" lines="none">
<ion-label position="stacked">Filter results:</ion-label>
<ion-input
v-model="resultsFilter"
placeholder="Type to filter..."
clearInput
></ion-input>
</ion-item>
</div>
<div v-if="filteredResults && filteredResults.length > 0" class="results-cards">
<ion-card v-for="(row, index) in filteredResults" :key="index" class="record-card">
<ion-card-header>
<ion-card-subtitle>Record {{ index + 1 }}</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<div v-for="column in Object.keys(row).filter(col => !shouldHideField(col))" :key="column" class="field-row">
<strong>{{ column }}:</strong>
<div class="field-value">
<!-- Edit mode for book field (single relation) -->
<div v-if="isEditMode && column === 'book' && !isSystemField(column)">
<select
v-model="editedData[column]"
class="table-select"
>
<option v-for="book in booksCache" :key="book.id" :value="book.id">
{{ book.name }}
</option>
</select>
</div>
<!-- Edit mode for simple fields -->
<div v-else-if="isEditMode && !Array.isArray(row[column]) && !isSystemField(column) && column !== 'book'">
<ion-textarea
v-model="editedData[column]"
:placeholder="formatValue(row[column])"
fill="outline"
:rows="2"
:auto-grow="true"
class="edit-input"
:type="isCurrencyField(column) ? 'number' : 'text'"
></ion-textarea>
<small v-if="isCurrencyField(column)" class="currency-hint">
💰 Editing in {{ preferences.currency }} (converted from base currency)
</small>
</div>
<!-- Handle relation data - editable in edit mode -->
<div v-else-if="Array.isArray(row[column]) && typeof row[column][0] === 'object'" class="relation-data">
<!-- Edit mode for relations -->
<div v-if="isEditMode" class="relation-edit">
<div class="relation-edit-header">{{ column }} Relations:</div>
<!-- Current relations as removable chips -->
<div v-if="editedRelations[column]?.length > 0" class="current-relations">
<ion-chip
v-for="relationId in editedRelations[column]"
:key="relationId"
@click="removeRelation(column, relationId)"
class="relation-chip removable"
>
{{ getRelationLabel(column, relationId) }}
<ion-icon :icon="close" class="remove-icon"></ion-icon>
</ion-chip>
</div>
<!-- Add new relations -->
<div class="add-relations">
<ion-button
fill="outline"
@click="openRelationSelector(column)"
class="relation-add-button"
>
Add {{ column }}...
</ion-button>
</div>
</div>
<!-- Read-only view -->
<div v-else>
<div v-for="(relatedItem, relIndex) in row[column]" :key="relIndex" class="related-item">
<div class="related-header">{{ column }} #{{ relIndex + 1 }}</div>
<div v-for="(relValue, relKey) in Object.fromEntries(Object.entries(relatedItem).filter(([key]) => !shouldHideField(key)))" :key="relKey" class="related-field">
<span class="related-key">{{ relKey }}:</span>
<span class="related-value">
<ShowCurrency v-if="relKey === 'quantity' && typeof relValue === 'number'" :currencyIn="relValue" />
<a v-else-if="typeof relKey === 'string' && isIdField(relKey) && relValue" @click="navigateToId(typeof relValue === 'string' ? relValue : String(relValue))" class="id-link">{{ relValue }}</a>
<span v-else>{{ relValue }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- Handle regular data (read-only) -->
<ShowCurrency v-else-if="column === 'quantity' && typeof row[column] === 'number'" :currencyIn="row[column]" />
<a v-else-if="isIdField(column) && row[column]" @click="navigateToId(typeof row[column] === 'string' ? row[column] : String(row[column]))" class="id-link">{{ row[column] }}</a>
<span v-else-if="isBookField(column)">{{ formatBookValue(row[column]) }}</span>
<span v-else>{{ formatValue(row[column]) }}</span>
</div>
</div>
</ion-card-content>
</ion-card>
</div>
<div v-else>
<p>No results found.</p>
</div>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
<!-- Single Offer Tech Handbook (shown when viewing /editor/offers/:id) -->
<ion-row v-if="isIdMode && selectedTable === 'offers'">
<ion-col>
<ion-card>
<ion-card-header>
<ion-card-title>Tech Handbook</ion-card-title>
</ion-card-header>
<ion-card-content>
<div v-if="singleOfferTechHandbookLoading" class="loading-state">
<ion-spinner name="crescent"></ion-spinner>
<p>Loading tech handbook...</p>
</div>
<div v-else-if="singleOfferTechHandbookError" class="error-state">
<p>{{ singleOfferTechHandbookError }}</p>
</div>
<div v-else-if="!singleOfferTechHandbook || singleOfferTechHandbook.items.length === 0" class="empty-state">
<p>No tech handbook entries for this offer.</p>
</div>
<div v-else>
<p class="tech-handbook-summary">
<strong>{{ singleOfferTechHandbook.items.length }}</strong> tech handbook item(s)
</p>
<ul class="tech-handbook-items">
<li v-for="item in singleOfferTechHandbook.items" :key="item.id" class="tech-handbook-item">
{{ item.content }}
</li>
</ul>
</div>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
<!-- Changeset Preview Dialog -->
<ion-modal :is-open="showChangesetDialog" @did-dismiss="showChangesetDialog = false">
<ion-header>
<ion-toolbar>
<ion-title>Proposed Changeset</ion-title>
<ion-buttons slot="end">
<ion-button @click="showChangesetDialog = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding changeset-modal-content">
<div v-if="proposedChangeset && proposedChangeset.changes && proposedChangeset.changes.length > 0">
<h3>Proposed Changeset</h3>
<p>This changeset will be submitted to the server and applied during sync.</p>
<p v-if="proposedChangeset.bookName" class="book-info">
<strong>Book:</strong> {{ proposedChangeset.bookName }}
</p>
<ion-card v-for="(changeItem, index) in proposedChangeset.changes" :key="index" class="changeset-card">
<ion-card-header>
<ion-card-title>{{ changeItem.operation.toUpperCase() }} - {{ changeItem.collection }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<div class="changeset-data">
<h4>Data:</h4>
<div v-for="(value, key) in changeItem.data" :key="key" class="data-field">
<strong>{{ key }}:</strong>
<span class="data-value">{{ formatValue(value) }}</span>
</div>
</div>
</ion-card-content>
</ion-card>
</div>
</ion-content>
<!-- Fixed Footer with Action Buttons -->
<ion-footer>
<ion-toolbar>
<div class="changeset-actions">
<ion-button fill="outline" @click="showChangesetDialog = false">Cancel</ion-button>
<ion-button color="primary" @click="submitChangeset" :disabled="loading">
<ion-spinner v-if="loading" name="crescent" slot="start"></ion-spinner>
{{ loading ? 'Submitting...' : 'Submit Changeset' }}
</ion-button>
</div>
</ion-toolbar>
</ion-footer>
</ion-modal>
<!-- Delete Confirmation Dialog -->
<ion-modal :is-open="showDeleteDialog" @did-dismiss="showDeleteDialog = false">
<ion-header>
<ion-toolbar>
<ion-title>Confirm Delete</ion-title>
<ion-buttons slot="end">
<ion-button @click="showDeleteDialog = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div v-if="proposedChangeset && proposedChangeset.isDelete">
<h3>Delete Record</h3>
<p>Are you sure you want to delete this record?</p>
<ion-card class="warning-card">
<ion-card-content>
<p><strong>Collection:</strong> {{ proposedChangeset.changes[0].collection }}</p>
<p v-if="proposedChangeset.bookName"><strong>Book:</strong> {{ proposedChangeset.bookName }}</p>
<p v-if="proposedChangeset.changes[0].data.refId">
<strong>Reference ID:</strong> {{ proposedChangeset.changes[0].data.refId }}
</p>
<p class="warning-text">
<ion-icon :icon="alertCircle" color="warning"></ion-icon>
This action cannot be undone!
</p>
</ion-card-content>
</ion-card>
<div class="dialog-actions">
<ion-button fill="outline" @click="showDeleteDialog = false">Cancel</ion-button>
<ion-button color="danger" @click="submitDelete">
<ion-icon :icon="trash" slot="start"></ion-icon>
Delete Record
</ion-button>
</div>
</div>
</ion-content>
</ion-modal>
<!-- Relation Selector Modal -->
<ion-modal :is-open="showRelationSelector" @did-dismiss="closeRelationSelector">
<ion-header>
<ion-toolbar>
<ion-title>Select {{ currentRelationField }}</ion-title>
<ion-buttons slot="end">
<ion-button @click="closeRelationSelector">Cancel</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding relation-modal-content">
<!-- Search/Filter Input -->
<ion-input
v-model="relationSearchText"
:placeholder="`Search ${currentRelationField}...`"
fill="outline"
class="relation-search-input"
></ion-input>
<!-- Available Relations List -->
<div class="relation-options">
<ion-item
v-for="option in getFilteredRelations(currentRelationField)"
:key="option.value"
button
@click="toggleRelationSelection(option.value)"
class="relation-option-item"
>
<ion-checkbox
:checked="selectedRelationsToAdd.includes(option.value)"
slot="start"
></ion-checkbox>
<ion-label>{{ option.label }}</ion-label>
</ion-item>
<div v-if="getFilteredRelations(currentRelationField).length === 0" class="no-results">
<p>No {{ currentRelationField }} found matching "{{ relationSearchText }}"</p>
</div>
</div>
</ion-content>
<!-- Fixed Footer with Action Buttons -->
<ion-footer>
<ion-toolbar>
<div class="relation-selector-actions">
<ion-button fill="outline" @click="closeRelationSelector">Cancel</ion-button>
<ion-button
color="primary"
@click="confirmRelationSelection"
:disabled="selectedRelationsToAdd.length === 0"
>
Add Selected ({{ selectedRelationsToAdd.length }})
</ion-button>
</div>
</ion-toolbar>
</ion-footer>
</ion-modal>
<!-- Change Detail Modal -->
<ion-modal :is-open="showChangeDetailModal" @did-dismiss="showChangeDetailModal = false">
<ion-header>
<ion-toolbar>
<ion-title>Change Details</ion-title>
<ion-buttons slot="end">
<ion-button @click="showChangeDetailModal = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div v-if="selectedChangeDetail">
<div class="detail-header">
<span class="op-badge" :class="selectedChangeDetail.operation">
{{ selectedChangeDetail.operation.toUpperCase() }}
</span>
<span class="collection-name">{{ selectedChangeDetail.collection }}</span>
</div>
<div class="detail-data">
<h4>Data</h4>
<div v-for="(value, key) in selectedChangeDetail.data" :key="key" class="detail-field">
<strong>{{ key }}:</strong>
<pre class="detail-value">{{ formatValue(value) }}</pre>
</div>
</div>
</div>
</ion-content>
</ion-modal>
<!-- Save Changeset Confirmation Dialog -->
<ion-modal :is-open="showSaveConfirmDialog" @did-dismiss="cancelSaveChangeset">
<ion-header>
<ion-toolbar color="warning">
<ion-title>Confirm Edit</ion-title>
<ion-buttons slot="end">
<ion-button @click="cancelSaveChangeset">Cancel</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="confirm-dialog-content">
<ion-icon :icon="alertCircle" class="confirm-icon" color="warning"></ion-icon>
<h3>Update Change Record?</h3>
<p>You are about to modify the change history in the local database.</p>
<p class="confirm-note">
This edit will be applied when the pricebook data is rebuilt from the change log.
Make sure your JSON changes are correct before proceeding.
</p>
<div v-if="pendingSaveIndex !== null && changeMessages[pendingSaveIndex]" class="confirm-details">
<strong>Record:</strong> {{ changeMessages[pendingSaveIndex].id }}<br>
<strong>Created:</strong> {{ formatDate(changeMessages[pendingSaveIndex].created) }}<br>
<strong>Book:</strong> {{ getBookName(changeMessages[pendingSaveIndex].book) }}
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<div class="confirm-actions">
<ion-button fill="outline" @click="cancelSaveChangeset">Cancel</ion-button>
<ion-button color="warning" @click="confirmSaveChangeset">
<ion-icon :icon="checkmark" slot="start"></ion-icon>
Save Changes
</ion-button>
</div>
</ion-toolbar>
</ion-footer>
</ion-modal>
<!-- Replay Confirmation Dialog -->
<ion-modal :is-open="showReplayConfirmDialog" @did-dismiss="cancelReplay">
<ion-header>
<ion-toolbar color="danger">
<ion-title>Replay All Changes</ion-title>
<ion-buttons slot="end">
<ion-button @click="cancelReplay">Cancel</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="confirm-dialog-content">
<ion-icon :icon="refreshOutline" class="confirm-icon" color="danger"></ion-icon>
<h3>Rebuild Database from Change Log?</h3>
<p>This will perform the following actions:</p>
<ol class="replay-steps">
<li>Clear all pricebook data (menus, offers, tiers, costs, etc.)</li>
<li>Replay all {{ changeMessages.length }} change messages in chronological order</li>
<li>Update each message's status based on replay result</li>
</ol>
<div class="replay-warning">
<ion-icon :icon="alertCircle" color="danger"></ion-icon>
<strong>Warning:</strong> This is a destructive operation. All current pricebook data will be deleted and rebuilt from the change log. Make sure you have saved any edits to the change messages before proceeding.
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<div class="confirm-actions">
<ion-button fill="outline" @click="cancelReplay">Cancel</ion-button>
<ion-button color="danger" @click="confirmReplay">
<ion-icon :icon="refreshOutline" slot="start"></ion-icon>
Replay All Changes
</ion-button>
</div>
</ion-toolbar>
</ion-footer>
</ion-modal>
<!-- Replay Progress Overlay -->
<div v-if="replayProgress" class="replay-progress-overlay">
<ion-card class="replay-progress-card">
<ion-card-content>
<ion-spinner name="crescent"></ion-spinner>
<h3>Replaying Changes...</h3>
<p>{{ replayProgress.status }}</p>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${(replayProgress.current / replayProgress.total) * 100}%` }"
></div>
</div>
<p class="progress-count">{{ replayProgress.current }} / {{ replayProgress.total }}</p>
</ion-card-content>
</ion-card>
</div>
<!-- Replay Results Dialog -->
<ion-modal :is-open="showReplayResultsDialog" @did-dismiss="showReplayResultsDialog = false">
<ion-header>
<ion-toolbar :color="replayResults.errors.length > 0 ? 'warning' : 'success'">
<ion-title>Replay Results</ion-title>
<ion-buttons slot="end">
<ion-button @click="showReplayResultsDialog = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="replay-results-summary">
<div class="result-stat success">
<span class="stat-number">{{ replayResults.success }}</span>
<span class="stat-label">Succeeded</span>
</div>
<div class="result-stat error">
<span class="stat-number">{{ replayResults.errors.length }}</span>
<span class="stat-label">Failed</span>
</div>
</div>
<div v-if="replayResults.errors.length > 0" class="replay-errors-section">
<h3>Error Log</h3>
<p class="errors-hint">The following changes failed to apply. You can edit them in the JSON view and replay again.</p>
<div v-for="err in replayResults.errors" :key="err.id" class="replay-error-item">
<div class="error-header">
<span class="error-id">ID: {{ err.id }}</span>
<span class="error-date">{{ formatDate(err.created) }}</span>
<span class="error-book">Book: {{ getBookName(err.book) }}</span>
</div>
<div class="error-message">
<ion-icon :icon="alertCircle" color="danger"></ion-icon>
{{ err.error }}
</div>
<div class="error-changeset">
<strong>Changeset:</strong>
<pre>{{ JSON.stringify(err.changeset, null, 2) }}</pre>
</div>
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<div class="confirm-actions">
<ion-button fill="outline" @click="copyReplayLog">
<ion-icon :icon="copyOutline" slot="start"></ion-icon>
Copy Log
</ion-button>
<ion-button color="primary" @click="showReplayResultsDialog = false">
Done
</ion-button>
</div>
</ion-toolbar>
</ion-footer>
</ion-modal>
<!-- Delete Database Confirmation Dialog -->
<ion-modal :is-open="showDeleteDbConfirmDialog" @did-dismiss="showDeleteDbConfirmDialog = false">
<ion-header>
<ion-toolbar color="dark">
<ion-title>Delete Database</ion-title>
<ion-buttons slot="end">
<ion-button @click="showDeleteDbConfirmDialog = false">Cancel</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="confirm-dialog-content">
<ion-icon :icon="skullOutline" class="confirm-icon skull-icon"></ion-icon>
<h3>Restore from Default</h3>
<p class="db-filename">Delete: <code>pricebookPlatformSQLite.db</code></p>
<p class="db-filename">Restore from: <code>default.sql</code></p>
<p>This will replace the current database with the default database.</p>
<ol class="replay-steps">
<li>Delete current database</li>
<li>Copy default.sql to pricebookPlatformSQLite.db</li>
<li>Reload the application</li>
</ol>
<div class="replay-warning">
<ion-icon :icon="alertCircle" color="danger"></ion-icon>
<strong>Warning:</strong> All local changes since the default was saved will be lost. Make sure you have saved a default database first using "Save as Default".
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<div class="confirm-actions">
<ion-button fill="outline" @click="showDeleteDbConfirmDialog = false">Cancel</ion-button>
<ion-button color="dark" @click="confirmDeleteDatabase">
<ion-icon :icon="skullOutline" slot="start"></ion-icon>
Restore Default
</ion-button>
</div>
</ion-toolbar>
</ion-footer>
</ion-modal>
<!-- Delete Database Progress Overlay -->
<div v-if="deleteDbProgress" class="replay-progress-overlay">
<ion-card class="replay-progress-card">
<ion-card-content>
<ion-spinner name="crescent"></ion-spinner>
<h3>Resetting Database...</h3>
<p>{{ deleteDbProgress.status }}</p>
</ion-card-content>
</ion-card>
</div>
<!-- DB File Picker Modal -->
<ion-modal :is-open="showDbPickerDialog" @did-dismiss="showDbPickerDialog = false">
<ion-header>
<ion-toolbar>
<ion-title>Database Browser</ion-title>
<ion-buttons slot="end">
<ion-button @click="showDbPickerDialog = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="db-picker-content">
<div v-if="loadingDatabases" class="db-loading">
<ion-spinner name="crescent"></ion-spinner>
<p>Loading databases...</p>
</div>
<template v-else>
<!-- SQLite Databases Section -->
<div class="db-section">
<h3 class="db-section-title">SQLite Databases</h3>
<p class="db-section-info">
These are the SQLite databases stored inside jeepSqliteStore.
</p>
<div v-if="sqliteDatabases.length === 0" class="db-empty-inline">
<p>No SQLite databases found.</p>
</div>
<div v-else class="db-list">
<div
v-for="dbName in sqliteDatabases"
:key="dbName"
class="db-item sqlite-item"
>
<div class="db-item-info">
<ion-icon :icon="serverOutline" class="db-icon sqlite-icon"></ion-icon>
<div class="db-details">
<span class="db-name">{{ dbName }}</span>
<span class="db-type">SQLite Database</span>
</div>
</div>
</div>
</div>
</div>
<!-- IndexedDB Section -->
<div class="db-section">
<h3 class="db-section-title">IndexedDB Stores</h3>
<p class="db-section-info">
Raw IndexedDB storage used by the SQLite web implementation.
</p>
<div v-if="availableDatabases.length === 0" class="db-empty-inline">
<p>No IndexedDB databases found.</p>
</div>
<div v-else class="db-list">
<div
v-for="db in availableDatabases"
:key="db.name"
class="db-item"
>
<div class="db-item-info">
<ion-icon :icon="folderOpenOutline" class="db-icon"></ion-icon>
<div class="db-details">
<span class="db-name">{{ db.name }}</span>
<span v-if="db.version" class="db-version">Version: {{ db.version }}</span>
</div>
</div>
<div class="db-item-actions">
<ion-button
size="small"
fill="outline"
color="danger"
@click="deleteSpecificDb(db.name)"
>
Delete
</ion-button>
</div>
</div>
</div>
</div>
</template>
<div class="db-picker-actions">
<ion-button fill="outline" @click="refreshDatabaseList">
<ion-icon :icon="refreshOutline" slot="start"></ion-icon>
Refresh List
</ion-button>
</div>
</div>
</ion-content>
</ion-modal>
<!-- Download All Changes Modal -->
<ion-modal :is-open="showDownloadedChangesModal" @did-dismiss="showDownloadedChangesModal = false">
<ion-header>
<ion-toolbar>
<ion-title>All Changes from API</ion-title>
<ion-buttons slot="end">
<ion-button @click="showDownloadedChangesModal = false">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div v-if="downloadedChangesLoading" class="loading-state">
<ion-spinner name="crescent"></ion-spinner>
<p>Downloading changes from API...</p>
</div>
<div v-else-if="downloadedChangesError" class="error-state">
<p>{{ downloadedChangesError }}</p>
</div>
<div v-else>
<p class="changes-summary">
<strong>{{ downloadedChanges.length }}</strong> changesets downloaded
</p>
<div class="downloaded-changes-controls">
<ion-button size="small" @click="copyAllDownloadedChanges">
<ion-icon :icon="copyOutline" slot="start"></ion-icon>
Copy All to Clipboard
</ion-button>
</div>
<pre class="downloaded-changes-code">{{ JSON.stringify(downloadedChanges, null, 2) }}</pre>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
IonGrid,
IonRow,
IonCol,
IonItem,
IonLabel,
IonInput,
IonTextarea,
IonButton,
IonCard,
IonCardHeader,
IonCardTitle,
IonCardSubtitle,
IonCardContent,
IonSpinner,
IonIcon,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonContent,
IonFooter,
IonChip,
IonCheckbox,
IonSegment,
IonSegmentButton,
} from "@ionic/vue";
import { arrowBack, pencil, checkmark, close, add, trash, alertCircle, listOutline, copyOutline, refreshOutline, skullOutline, folderOpenOutline, serverOutline, saveOutline, cloudUploadOutline, cloudDownloadOutline, gitMergeOutline } from "ionicons/icons";
import BaseLayout from "@/components/BaseLayout.vue";
import ShowCurrency from "@/components/ShowCurrency.vue";
import { getSQLite } from "@/dataAccess/getSQLite";
import { getApi } from "@/dataAccess/getApi";
import { getDb } from "@/dataAccess/getDb";
import {
convertToBaseCurrency,
convertFromBaseCurrency,
} from "@/utils/currencyConverter";
import { useCurrencyStore } from "@/stores/currency";
import { usePreferencesStore } from "@/stores/preferences";
import { useSessionStore } from "@/stores/session";
import { ChangesetProcessor } from "@/dataAccess/changeset-processor";
import { clearAllIndexedDB } from "@/dataAccess/deleteDb";
const route = useRoute();
const router = useRouter();
const currencyStore = useCurrencyStore();
const preferences = usePreferencesStore();
const sessionStore = useSessionStore();
const tableName = ref("");
const selectedTable = ref("");
const results = ref<any>(null);
const error = ref("");
const loading = ref(false);
const lastQuery = ref("");
const searchId = ref("");
const isIdMode = ref(false);
const isEditMode = ref(false);
const editedData = ref<any>({});
const originalData = ref<any>({});
const showChangesetDialog = ref(false);
const proposedChangeset = ref<any>(null);
const showDeleteDialog = ref(false);
const editedRelations = ref<any>({});
const availableRelations = ref<any>({});
const showRelationSelector = ref(false);
const currentRelationField = ref("");
const relationSearchText = ref("");
const selectedRelationsToAdd = ref<string[]>([]);
const booksCache = ref<any[]>([]);
const isCreateMode = ref(false);
const createSelectedCollection = ref("");
const createFields = ref<any[]>([]);
const createData = ref<any>({});
const resultsFilter = ref("");
// Change messages / history
const showChangeHistory = ref(false);
const changeHistoryViewMode = ref<'friendly' | 'json'>('friendly');
const changeMessages = ref<any[]>([]);
const jsonErrors = ref<Record<number, string>>({});
const jsonEdited = ref<Record<number, boolean>>({});
const jsonEditedContent = ref<Record<number, string>>({});
const showChangeDetailModal = ref(false);
const selectedChangeDetail = ref<any>(null);
const showSaveConfirmDialog = ref(false);
const pendingSaveIndex = ref<number | null>(null);
const showReplayConfirmDialog = ref(false);
const replayProgress = ref<{ current: number; total: number; status: string } | null>(null);
const showReplayResultsDialog = ref(false);
const replayResults = ref<{ success: number; errors: Array<{ id: number; created: string; book: string; error: string; changeset: any[] }> }>({ success: 0, errors: [] });
// Super secret mode (secret mode + 4 dark mode toggles)
const superSecretMode = ref(false);
const lastToggleCount = ref(0);
const showDeleteDbConfirmDialog = ref(false);
const deleteDbProgress = ref<{ status: string } | null>(null);
// DB File Picker
const showDbPickerDialog = ref(false);
const availableDatabases = ref<Array<{ name: string; version: number | null }>>([]);
const sqliteDatabases = ref<string[]>([]);
const loadingDatabases = ref(false);
// Download All Changes
const showDownloadedChangesModal = ref(false);
const downloadedChanges = ref<any[]>([]);
const downloadedChangesLoading = ref(false);
const downloadedChangesError = ref("");
// Tech Handbook Browser
const showTechHandbook = ref(false);
const techHandbookData = ref<{ offerId: string; offerName: string; items: any[] }[]>([]);
const techHandbookLoading = ref(false);
const techHandbookError = ref("");
// Single Offer Tech Handbook (for /editor/offers/:id route)
const singleOfferTechHandbook = ref<{ offerId: string; offerName: string; items: any[] } | null>(null);
const singleOfferTechHandbookLoading = ref(false);
const singleOfferTechHandbookError = ref("");
// Computed property for filtered results
const filteredResults = computed(() => {
if (!results.value?.values) return [];
if (!resultsFilter.value.trim()) return results.value.values;
const filterText = resultsFilter.value.toLowerCase();
return results.value.values.filter((row: any) => {
// Check all field values for the filter text
for (const [key, value] of Object.entries(row)) {
// Skip hidden fields
if (shouldHideField(key)) continue;
// Convert value to string for searching
let searchValue = '';
if (value === null || value === undefined) {
searchValue = 'null';
} else if (typeof value === 'object') {
searchValue = JSON.stringify(value);
} else {
searchValue = String(value);
}
// Check if the value contains the filter text
if (searchValue.toLowerCase().includes(filterText)) {
return true;
}
// Also check formatted values for special fields
if (isBookField(key)) {
const formattedValue = formatBookValue(value);
if (formattedValue.toLowerCase().includes(filterText)) {
return true;
}
}
}
return false;
});
});
// Allowed tables from changeset processor plus additional core tables
const allowedTables = [
"menus",
"offers",
"menuTiers",
"contentItems",
"menuCopy",
"problems",
"problemTags",
"checklists",
"costsMaterial",
"costsTime",
"tierSets",
"tiers",
"formulas",
"books",
];
// Fields that contain relation data (JSON arrays of IDs)
const relationFields = new Set([
'menus', 'offers', 'menuTiers', 'contentItems', 'menuCopy',
'problems', 'problemTags', 'checklists', 'costsMaterial',
'costsTime', 'tierSets', 'tiers', 'formulas', 'books'
]);
const expandRelationField = async (dbConn: any, fieldName: string, value: any) => {
if (!value || !relationFields.has(fieldName)) return value;
try {
const ids = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(ids) || ids.length === 0) return value;
const expandedData = [];
for (const id of ids) {
try {
const relatedQuery = `SELECT * FROM ${fieldName} WHERE id = '${id}'`;
const relatedResult = await dbConn.query(relatedQuery);
if (relatedResult.values && relatedResult.values.length > 0) {
expandedData.push(relatedResult.values[0]);
}
} catch (e) {
// If we can't find the related record, just keep the ID
expandedData.push({ id, _error: 'Record not found' });
}
}
return expandedData;
} catch (e) {
// If parsing fails, return original value
return value;
}
};
const searchAllTablesForId = async (id: string) => {
loading.value = true;
error.value = "";
results.value = null;
lastQuery.value = `Searching all tables for ID: ${id}`;
try {
const { dbConn } = await getSQLite();
let foundRecord = null;
let foundTable = "";
// Search through all allowed tables
for (const table of allowedTables) {
try {
const query = `SELECT * FROM ${table} WHERE id = '${id}'`;
const result = await dbConn.query(query);
if (result.values && result.values.length > 0) {
foundRecord = result.values[0];
foundTable = table;
break;
}
} catch (e) {
// Table might not exist, continue searching
continue;
}
}
if (foundRecord) {
// Store the original record data (including book field)
originalData.value = { ...foundRecord };
// Expand relation fields for the found record
const expandedRow = { ...foundRecord };
for (const [fieldName, value] of Object.entries(foundRecord)) {
expandedRow[fieldName] = await expandRelationField(dbConn, fieldName, value);
}
results.value = {
values: [expandedRow],
foundInTable: foundTable
};
lastQuery.value = `Found in table: ${foundTable}`;
} else {
results.value = { values: [] };
lastQuery.value = `ID "${id}" not found in any table`;
}
} catch (err: any) {
error.value = err.message || "An error occurred while searching for the ID";
} finally {
loading.value = false;
}
};
const executeQuery = async () => {
if (!tableName.value.trim()) return;
loading.value = true;
error.value = "";
results.value = null;
resultsFilter.value = "";
const query = `SELECT * FROM ${tableName.value.trim()}`;
lastQuery.value = query;
try {
const { dbConn } = await getSQLite();
const result = await dbConn.query(query);
// Expand relation fields
if (result.values && result.values.length > 0) {
const expandedValues = [];
for (const row of result.values) {
const expandedRow = { ...row };
for (const [fieldName, value] of Object.entries(row)) {
expandedRow[fieldName] = await expandRelationField(dbConn, fieldName, value);
}
expandedValues.push(expandedRow);
}
result.values = expandedValues;
}
results.value = result;
} catch (err: any) {
error.value = err.message || "An error occurred while executing the query";
} finally {
loading.value = false;
}
};
const formatValue = (value: any) => {
if (value === null || value === undefined) return 'null';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
const isIdField = (fieldName: string) => {
return fieldName === 'id';
};
const isSystemField = (fieldName: string) => {
return ['id', 'created', 'updated', 'refId'].includes(fieldName);
};
const shouldHideField = (fieldName: string) => {
return fieldName === 'org';
};
const isCurrencyField = (fieldName: string) => {
return fieldName === 'quantity';
};
const isBookField = (fieldName: string) => {
return fieldName === 'book' || fieldName === 'books';
};
const getBookName = (bookId: string) => {
const book = booksCache.value.find(b => b.id === bookId);
return book ? book.name : bookId;
};
const formatBookValue = (value: any) => {
// Handle single book ID
if (typeof value === 'string') {
return getBookName(value);
}
// Handle array of book IDs (e.g., for menus.books field)
if (Array.isArray(value)) {
return value.map(id => getBookName(id)).join(', ');
}
// Handle JSON string of book IDs
if (typeof value === 'string' && (value.startsWith('[') || value.startsWith('{'))) {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed.map(id => getBookName(id)).join(', ');
}
} catch {
// Not valid JSON, return as-is
}
}
return value;
};
const loadAvailableRelations = async (relationFieldName: string) => {
try {
const { dbConn } = await getSQLite();
const query = `SELECT DISTINCT id, name, refId FROM ${relationFieldName} ORDER BY name`;
const result = await dbConn.query(query);
if (result.values) {
// Create a Map to ensure uniqueness by ID
const uniqueRelations = new Map();
result.values.forEach(item => {
if (!uniqueRelations.has(item.id)) {
const nameLabel = item.name || item.id;
const refIdLabel = item.refId ? ` (${item.refId})` : '';
uniqueRelations.set(item.id, {
id: item.id,
refId: item.refId || item.id, // Store refId for later use
label: `${nameLabel}${refIdLabel}`,
value: item.id
});
}
});
return Array.from(uniqueRelations.values());
}
return [];
} catch (error) {
console.warn(`Failed to load relations for ${relationFieldName}:`, error);
return [];
}
};
const navigateToId = (id: string) => {
router.push(`/editor/${id}`);
};
const navigateToCreate = () => {
router.push('/editor/create');
};
const lookupId = ref('');
const handleLookup = () => {
if (lookupId.value.trim()) {
router.push(`/editor/${lookupId.value.trim()}`);
}
};
const browseTechHandbook = async () => {
showTechHandbook.value = !showTechHandbook.value;
if (!showTechHandbook.value) {
return;
}
techHandbookLoading.value = true;
techHandbookError.value = "";
techHandbookData.value = [];
try {
const sql = await getSQLite();
const query = `
SELECT
o.id AS offer_id,
o.name AS offer_name,
ci.*
FROM offers o
JOIN json_each(o.techhandbook) AS j
ON 1=1
JOIN contentItems ci
ON ci.id = j.value
`;
const result = await sql.dbConn.query(query);
if (result.values && result.values.length > 0) {
// Group results by offer
const groupedData: Map<string, { offerId: string; offerName: string; items: any[] }> = new Map();
for (const row of result.values) {
const offerId = row.offer_id;
const offerName = row.offer_name;
if (!groupedData.has(offerId)) {
groupedData.set(offerId, { offerId, offerName, items: [] });
}
groupedData.get(offerId)!.items.push(row);
}
techHandbookData.value = Array.from(groupedData.values());
console.log('[Editor] Tech Handbook loaded:', techHandbookData.value.length, 'offers');
} else {
techHandbookData.value = [];
}
} catch (e: any) {
console.error('[Editor] Tech Handbook query failed:', e);
techHandbookError.value = e.message || 'Failed to load tech handbook data';
} finally {
techHandbookLoading.value = false;
}
};
const loadSingleOfferTechHandbook = async (offerId: string) => {
singleOfferTechHandbookLoading.value = true;
singleOfferTechHandbookError.value = "";
singleOfferTechHandbook.value = null;
try {
const sql = await getSQLite();
const query = `
SELECT
o.id AS offer_id,
o.name AS offer_name,
ci.*
FROM offers o
JOIN json_each(o.techhandbook) AS j
ON 1=1
JOIN contentItems ci
ON ci.id = j.value
WHERE o.id = '${offerId}'
`;
const result = await sql.dbConn.query(query);
if (result.values && result.values.length > 0) {
const firstRow = result.values[0];
singleOfferTechHandbook.value = {
offerId: firstRow.offer_id,
offerName: firstRow.offer_name,
items: result.values,
};
console.log('[Editor] Single Offer Tech Handbook loaded:', result.values.length, 'items');
} else {
singleOfferTechHandbook.value = null;
}
} catch (e: any) {
console.error('[Editor] Single Offer Tech Handbook query failed:', e);
singleOfferTechHandbookError.value = e.message || 'Failed to load tech handbook data';
} finally {
singleOfferTechHandbookLoading.value = false;
}
};
const createNewBook = async () => {
const { alertController, toastController } = await import('@ionic/vue');
const alert = await alertController.create({
header: 'Create New Book',
inputs: [
{
name: 'name',
type: 'text',
placeholder: 'Book name (e.g., My Pricebook)',
},
{
name: 'refId',
type: 'text',
placeholder: 'Reference ID (e.g., my-pricebook)',
},
],
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Create',
handler: async (data) => {
if (!data.name || !data.refId) {
const toast = await toastController.create({
message: 'Please provide both name and reference ID',
duration: 3000,
color: 'warning'
});
await toast.present();
return false;
}
try {
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
// Create the book via PocketBase API
const newBook = await pb.collection('books').create({
name: data.name,
refId: data.refId,
org: activeOrg,
});
console.log('[Editor] Created new book:', newBook);
// Refresh books in the local database
const db = await getDb();
if (db?.books?.refresh) {
await db.books.refresh();
}
const toast = await toastController.create({
message: `Book "${data.name}" created successfully`,
duration: 3000,
color: 'success'
});
await toast.present();
} catch (e: any) {
console.error('[Editor] Failed to create book:', e);
const toast = await toastController.create({
message: `Failed to create book: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
};
const goBackToBrowse = () => {
router.push('/editor');
};
const removeRelation = (fieldName: string, relationId: string) => {
if (editedRelations.value[fieldName]) {
editedRelations.value[fieldName] = editedRelations.value[fieldName].filter((id: string) => id !== relationId);
}
};
const onSingleRelationChange = (fieldName: string, event: any) => {
// For single relations, the value is directly set via v-model
// This handler is just for any additional processing if needed
console.log(`Single relation ${fieldName} changed to:`, editedRelations.value[fieldName]);
};
const addRelation = (fieldName: string, relationIds: string[]) => {
if (!editedRelations.value[fieldName]) {
editedRelations.value[fieldName] = [];
}
// Add only new relations that aren't already present
const newIds = relationIds.filter(id => !editedRelations.value[fieldName].includes(id));
editedRelations.value[fieldName].push(...newIds);
};
const getRelationLabel = (fieldName: string, relationId: string) => {
const relation = availableRelations.value[fieldName]?.find((r: any) => r.value === relationId);
return relation?.label || relationId;
};
const getFilteredRelations = (fieldName: string) => {
const available = availableRelations.value[fieldName] || [];
const excluded = editedRelations.value[fieldName] || [];
const filter = relationSearchText.value.toLowerCase() || '';
return available
.filter((opt: any) => !excluded.includes(opt.value))
.filter((opt: any) => filter === '' || opt.label.toLowerCase().includes(filter));
};
const openRelationSelector = (fieldName: string) => {
currentRelationField.value = fieldName;
relationSearchText.value = "";
selectedRelationsToAdd.value = [];
showRelationSelector.value = true;
};
const closeRelationSelector = () => {
showRelationSelector.value = false;
currentRelationField.value = "";
relationSearchText.value = "";
selectedRelationsToAdd.value = [];
};
const toggleRelationSelection = (relationId: string) => {
const index = selectedRelationsToAdd.value.indexOf(relationId);
if (index > -1) {
selectedRelationsToAdd.value.splice(index, 1);
} else {
selectedRelationsToAdd.value.push(relationId);
}
};
const confirmRelationSelection = () => {
if (selectedRelationsToAdd.value.length > 0) {
addRelation(currentRelationField.value, selectedRelationsToAdd.value);
}
closeRelationSelector();
};
const resetToInitialState = () => {
tableName.value = "";
selectedTable.value = "";
results.value = null;
error.value = "";
lastQuery.value = "";
searchId.value = "";
isIdMode.value = false;
isEditMode.value = false;
isCreateMode.value = false;
editedData.value = {};
originalData.value = {};
editedRelations.value = {};
availableRelations.value = {};
createSelectedCollection.value = "";
createFields.value = [];
createData.value = {};
};
const startEditMode = async () => {
if (results.value?.values?.[0]) {
const record = results.value.values[0];
originalData.value = { ...record };
// Initialize editedData with current values (excluding system fields and relations)
editedData.value = {};
editedRelations.value = {};
availableRelations.value = {};
for (const [key, value] of Object.entries(record)) {
if (!isSystemField(key) && !shouldHideField(key)) {
if (Array.isArray(value) && typeof value[0] === 'object') {
// This is a relation field - initialize for editing
editedRelations.value[key] = value.map(item => item.id);
// Load available options for this relation
availableRelations.value[key] = await loadAvailableRelations(key);
} else if (!Array.isArray(value)) {
// Regular field or book field
if (key === 'book') {
// Book is a single relation - store the ID directly
editedData.value[key] = value;
} else if (isCurrencyField(key) && typeof value === 'number') {
// Convert from base currency to user's preferred currency for editing
try {
const convertedValue = convertFromBaseCurrency(value);
editedData.value[key] = convertedValue;
} catch (error) {
console.warn(`Failed to convert currency for ${key}:`, error);
editedData.value[key] = value; // Fallback to original value
}
} else {
editedData.value[key] = value;
}
}
}
}
isEditMode.value = true;
}
};
const cancelEdit = () => {
isEditMode.value = false;
editedData.value = {};
originalData.value = {};
editedRelations.value = {};
availableRelations.value = {};
};
const confirmDelete = async () => {
// Get the book ID from originalData (which has the raw record data)
const bookId = originalData.value?.book || results.value?.values?.[0]?.book;
// Get the refId from the original data
const refId = originalData.value?.refId || results.value?.values?.[0]?.refId;
// Get the book name for display
let bookName = 'Unknown Book';
if (bookId) {
try {
const { dbConn } = await getSQLite();
const bookResult = await dbConn.query(
`SELECT name FROM books WHERE id = '${bookId}'`
);
if (bookResult.values && bookResult.values.length > 0) {
bookName = bookResult.values[0].name;
}
} catch (e) {
console.warn('Could not fetch book name:', e);
}
}
// Prepare delete changeset
proposedChangeset.value = {
changes: [{
collection: results.value.foundInTable,
operation: 'delete',
data: {
refId: refId
}
}],
bookName: bookName,
bookId: bookId,
isDelete: true
};
showDeleteDialog.value = true;
};
const submitDelete = async () => {
try {
loading.value = true;
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
const bookId = proposedChangeset.value.bookId;
if (!bookId) {
throw new Error('Record does not have an associated book');
}
// Submit delete changeset
const changesetRecord = await pb.collection('pricebookChanges').create({
book: bookId,
org: activeOrg,
changeset: proposedChangeset.value.changes
});
console.log('Delete changeset submitted:', changesetRecord);
showDeleteDialog.value = false;
// Show success message
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'Record deleted successfully. Syncing database...',
duration: 2000,
color: 'success'
});
await toast.present();
// Sync the database
const { syncDatabase } = await import('@/dataAccess/getDb');
const syncResult = await syncDatabase();
if (syncResult.success) {
const syncToast = await toastController.create({
message: 'Database synced successfully',
duration: 2000,
color: 'success'
});
await syncToast.present();
}
// Navigate back to browse since the record is deleted
goBackToBrowse();
} catch (error: any) {
console.error('Error deleting record:', error);
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Failed to delete record: ${error.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
} finally {
loading.value = false;
}
};
const saveChanges = async () => {
if (!originalData.value || !editedData.value) return;
// Generate changeset in ChangesetProcessor format
const data: any = {};
let hasChanges = false;
// Include refId for updates
if (originalData.value.refId) {
data.refId = originalData.value.refId;
}
// Check regular field changes and add all current values to data
for (const [key, newValue] of Object.entries(editedData.value)) {
const oldValue = originalData.value[key];
let valueToCompare = newValue;
let valueToSave = newValue;
// Skip the book field here - it will be handled separately
if (key === 'book') {
// Book changes are tracked separately
if (newValue !== oldValue) {
hasChanges = true;
data[key] = newValue; // Book ID is stored directly
}
continue;
}
// If this is a currency field, convert back to base currency for comparison and storage
if (isCurrencyField(key) && typeof newValue === 'number') {
try {
valueToSave = convertToBaseCurrency(newValue);
valueToCompare = valueToSave; // Compare converted values
} catch (error) {
console.warn(`Failed to convert currency for ${key}:`, error);
// If conversion fails, use the original value (no change)
valueToCompare = oldValue;
valueToSave = oldValue;
}
}
// Always include the field value in data
data[key] = valueToSave;
// Track if there are changes
if (valueToCompare !== oldValue) {
hasChanges = true;
}
}
// Check relation changes and prepare refs structure
const refs: any[] = [];
for (const [key, newRelationIds] of Object.entries(editedRelations.value)) {
const originalRelations = originalData.value[key];
const originalIds = originalRelations?.map((item: any) => item.id) || [];
// Compare arrays of IDs
const idsChanged = JSON.stringify(originalIds.sort()) !== JSON.stringify((newRelationIds as string[]).sort());
if (idsChanged) {
hasChanges = true;
}
// Add to refs structure - include empty arrays to clear relations
if (idsChanged || (newRelationIds && (newRelationIds as string[]).length > 0)) {
const refIds = (newRelationIds as string[]).map((id: string) => {
const relation = availableRelations.value[key]?.find((r: any) => r.value === id);
return relation?.refId || id; // Use stored refId or fallback to id
});
refs.push({
collection: key,
refIds: refIds // This will be an empty array if newRelationIds is empty
});
}
}
// Add refs to data if there are any
if (refs.length > 0) {
data.refs = refs;
}
if (hasChanges) {
// Get the book name if we have a book ID
let bookName = 'Unknown Book';
if (originalData.value.book) {
try {
const { dbConn } = await getSQLite();
const bookResult = await dbConn.query(
`SELECT name FROM books WHERE id = '${originalData.value.book}'`
);
if (bookResult.values && bookResult.values.length > 0) {
bookName = bookResult.values[0].name;
}
} catch (e) {
console.warn('Could not fetch book name:', e);
}
}
// Create changeset array with single update operation
proposedChangeset.value = {
changes: [{
collection: results.value.foundInTable,
operation: 'update',
data: data
}],
bookName: bookName,
bookId: originalData.value.book
};
showChangesetDialog.value = true;
} else {
// No changes to save
cancelEdit();
}
};
const submitChangeset = async () => {
if (!proposedChangeset.value) return;
loading.value = true;
try {
// Get the API instance
const { pb } = await getApi();
// Get the active organization
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
// Get the book ID from the record itself - it's stored in the book column
const bookId = originalData.value.book;
if (!bookId) {
throw new Error('Record does not have an associated book');
}
// Create the pricebookChanges record using the book's ID
const changesetRecord = await pb.collection('pricebookChanges').create({
book: bookId,
org: activeOrg,
changeset: proposedChangeset.value.changes
});
console.log('Changeset submitted:', changesetRecord);
// Close the dialog
showChangesetDialog.value = false;
// Show success message
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'Changes submitted successfully. Syncing database...',
duration: 2000,
color: 'success'
});
await toast.present();
// Sync the database to pull down the changes
const { syncDatabase } = await import('@/dataAccess/getDb');
const syncResult = await syncDatabase();
if (syncResult.success) {
const syncToast = await toastController.create({
message: 'Database synced successfully',
duration: 2000,
color: 'success'
});
await syncToast.present();
// Refresh the current data by re-fetching
if (isIdMode.value) {
await searchAllTablesForId(searchId.value);
}
} else {
const syncToast = await toastController.create({
message: syncResult.connected ? 'Sync completed with warnings' : 'Offline - changes saved to server',
duration: 3000,
color: 'warning'
});
await syncToast.present();
}
// Exit edit mode
cancelEdit();
} catch (error: any) {
console.error('Error submitting changeset:', error);
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Failed to submit changes: ${error.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
} finally {
loading.value = false;
}
};
// Change messages functions
const loadChangeMessages = async () => {
try {
const sql = await getSQLite();
const result = await sql.dbConn.query(
`SELECT id, book, changeset, status, error_message, created
FROM pricebookChanges
ORDER BY created DESC
LIMIT 100`
);
if (result.values && result.values.length > 0) {
// Parse the changeset JSON for each row
changeMessages.value = result.values.map((row: any) => ({
...row,
changeset: row.changeset ? JSON.parse(row.changeset) : [],
}));
console.log('[Editor] Loaded', changeMessages.value.length, 'pricebook changes');
} else {
changeMessages.value = [];
}
} catch (e: any) {
console.error('[Editor] Failed to load pricebook changes:', e);
changeMessages.value = [];
}
};
const toggleChangeHistory = async () => {
showChangeHistory.value = !showChangeHistory.value;
if (showChangeHistory.value) {
await loadChangeMessages();
// Reset edit state
jsonEdited.value = {};
jsonEditedContent.value = {};
jsonErrors.value = {};
}
};
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr);
return date.toLocaleString();
} catch {
return dateStr;
}
};
const getPreviewData = (data: any) => {
if (!data) return {};
const preview: Record<string, any> = {};
const keys = Object.keys(data).filter(k => k !== 'refs').slice(0, 4);
for (const key of keys) {
preview[key] = data[key];
}
return preview;
};
const truncateValue = (value: any) => {
const str = typeof value === 'object' ? JSON.stringify(value) : String(value);
return str.length > 50 ? str.substring(0, 50) + '...' : str;
};
const expandChange = (msg: any, changeIndex: number) => {
selectedChangeDetail.value = msg.changeset[changeIndex];
showChangeDetailModal.value = true;
};
const formatJsonForEdit = (msg: any) => {
return JSON.stringify({
id: msg.id,
book: msg.book,
status: msg.status,
created: msg.created,
changeset: msg.changeset
}, null, 2);
};
const updateChangesetJson = (msgIndex: number, event: Event) => {
const target = event.target as HTMLTextAreaElement;
const originalJson = formatJsonForEdit(changeMessages.value[msgIndex]);
// Track if content has been edited
jsonEdited.value[msgIndex] = target.value !== originalJson;
jsonEditedContent.value[msgIndex] = target.value;
try {
JSON.parse(target.value);
jsonErrors.value[msgIndex] = '';
} catch (e: any) {
jsonErrors.value[msgIndex] = `Invalid JSON: ${e.message}`;
}
};
const saveChangeset = (msgIndex: number) => {
pendingSaveIndex.value = msgIndex;
showSaveConfirmDialog.value = true;
};
const confirmSaveChangeset = async () => {
const msgIndex = pendingSaveIndex.value;
if (msgIndex === null) return;
const editedJson = jsonEditedContent.value[msgIndex];
if (!editedJson) return;
try {
const parsed = JSON.parse(editedJson);
const msg = changeMessages.value[msgIndex];
// Update the database record
const db = await getDb();
if (db) {
await db.changeMessages.update(msg.id, parsed.changeset);
}
// Update the local changeMessages array
changeMessages.value[msgIndex] = {
...msg,
changeset: parsed.changeset
};
// Clear the edited state
jsonEdited.value[msgIndex] = false;
delete jsonEditedContent.value[msgIndex];
showSaveConfirmDialog.value = false;
pendingSaveIndex.value = null;
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'Change message updated in database',
duration: 2000,
color: 'success'
});
await toast.present();
} catch (e: any) {
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Failed to save: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
};
const cancelSaveChangeset = () => {
showSaveConfirmDialog.value = false;
pendingSaveIndex.value = null;
};
const confirmReplay = async () => {
showReplayConfirmDialog.value = false;
replayProgress.value = { current: 0, total: 0, status: 'Preparing...' };
replayResults.value = { success: 0, errors: [] };
try {
const { dbConn, saveDb } = await getSQLite();
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
// Tables to clear (all pricebook data tables)
const tablesToClear = [
'menus',
'offers',
'menuTiers',
'contentItems',
'menuCopy',
'warrantyCopy',
'problems',
'problemTags',
'checklists',
'costsMaterial',
'costsTime',
'tierSets',
'tiers',
'formulas'
];
replayProgress.value = { current: 0, total: tablesToClear.length + changeMessages.value.length, status: 'Clearing tables...' };
// Clear all pricebook data tables
for (const table of tablesToClear) {
try {
await dbConn.execute(`DELETE FROM ${table}`);
replayProgress.value.current++;
replayProgress.value.status = `Cleared ${table}`;
} catch (e) {
console.warn(`Could not clear table ${table}:`, e);
}
}
await saveDb();
// Sort changeMessages by created date (oldest first)
const sortedMessages = [...changeMessages.value].sort((a, b) =>
new Date(a.created).getTime() - new Date(b.created).getTime()
);
replayProgress.value.status = 'Replaying changes...';
// Process each changeset
const processor = new ChangesetProcessor(dbConn);
const db = await getDb();
let successCount = 0;
const errors: Array<{ id: number; created: string; book: string; error: string; changeset: any[] }> = [];
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i];
replayProgress.value.current = tablesToClear.length + i + 1;
replayProgress.value.status = `Processing change ${i + 1} of ${sortedMessages.length}...`;
try {
await processor.processChangeset(
msg.changeset,
activeOrg,
false, // Don't use transaction per change, we'll save at the end
msg.book
);
// Update status in changeMessages table
if (db) {
await dbConn.run(
`UPDATE changeMessages SET status = 'success', error_message = NULL WHERE id = ?`,
[msg.id]
);
}
successCount++;
} catch (e: any) {
const errorMessage = e.message || String(e);
console.error(`Error replaying change ${msg.id}:`, e);
// Track error for results log
errors.push({
id: msg.id,
created: msg.created,
book: msg.book,
error: errorMessage,
changeset: msg.changeset
});
// Update status in changeMessages table
if (db) {
await dbConn.run(
`UPDATE changeMessages SET status = 'error', error_message = ? WHERE id = ?`,
[errorMessage, msg.id]
);
}
}
}
await saveDb();
replayProgress.value = null;
// Store results
replayResults.value = { success: successCount, errors };
// Reload change messages to show updated statuses
await loadChangeMessages();
// Show results dialog if there were any errors, otherwise just show toast
if (errors.length > 0) {
showReplayResultsDialog.value = true;
} else {
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Replay complete: ${successCount} changes applied successfully`,
duration: 4000,
color: 'success'
});
await toast.present();
}
} catch (e: any) {
replayProgress.value = null;
console.error('Replay failed:', e);
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Replay failed: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
};
const cancelReplay = () => {
showReplayConfirmDialog.value = false;
};
const confirmDeleteDatabase = async () => {
showDeleteDbConfirmDialog.value = false;
deleteDbProgress.value = { status: 'Preparing to restore from default...' };
try {
// Step 1: Copy default.sql to pricebookPlatformSQLite.db in jeepSqliteStore
deleteDbProgress.value = { status: 'Restoring from default.sql...' };
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('jeepSqliteStore');
request.onerror = () => reject(new Error('Could not open jeepSqliteStore'));
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('databases')) {
db.close();
reject(new Error('databases object store not found'));
return;
}
const transaction = db.transaction(['databases'], 'readwrite');
const store = transaction.objectStore('databases');
// Get the default database
const getRequest = store.get('default.sql');
getRequest.onsuccess = () => {
if (!getRequest.result) {
db.close();
reject(new Error('default.sql not found. Please save a default database first using "Save as Default".'));
return;
}
// Delete the current database
const deleteRequest = store.delete('pricebookPlatformSQLite.db');
deleteRequest.onsuccess = () => {
// Copy default.sql to pricebookPlatformSQLite.db
const putRequest = store.put(getRequest.result, 'pricebookPlatformSQLite.db');
putRequest.onsuccess = () => {
db.close();
resolve();
};
putRequest.onerror = () => {
db.close();
reject(new Error('Failed to restore database'));
};
};
deleteRequest.onerror = () => {
db.close();
reject(new Error('Failed to delete current database'));
};
};
getRequest.onerror = () => {
db.close();
reject(new Error('Failed to read default.sql'));
};
};
});
deleteDbProgress.value = { status: 'Database restored. Reloading...' };
await new Promise(resolve => setTimeout(resolve, 500));
// Reload the page to get fresh database connections
window.location.reload();
} catch (e: any) {
deleteDbProgress.value = null;
console.error('Delete database failed:', e);
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Failed to delete database: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
};
// DB File Picker functions
const openDbPicker = async () => {
showDbPickerDialog.value = true;
await refreshDatabaseList();
};
const refreshDatabaseList = async () => {
loadingDatabases.value = true;
availableDatabases.value = [];
sqliteDatabases.value = [];
try {
if ('indexedDB' in window && indexedDB.databases) {
const databases = await indexedDB.databases();
availableDatabases.value = databases.map(db => ({
name: db.name || 'Unknown',
version: db.version || null
}));
// Try to get SQLite databases from jeepSqliteStore
await loadSqliteDatabasesFromStore();
} else {
// Fallback: indexedDB.databases() not supported in all browsers
console.warn('indexedDB.databases() not supported');
}
} catch (e) {
console.error('Failed to list databases:', e);
} finally {
loadingDatabases.value = false;
}
};
const loadSqliteDatabasesFromStore = async () => {
return new Promise<void>((resolve) => {
const request = indexedDB.open('jeepSqliteStore');
request.onerror = () => {
console.warn('Could not open jeepSqliteStore');
resolve();
};
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Check if the 'databases' object store exists
if (!db.objectStoreNames.contains('databases')) {
db.close();
resolve();
return;
}
const transaction = db.transaction(['databases'], 'readonly');
const store = transaction.objectStore('databases');
const getAllRequest = store.getAllKeys();
getAllRequest.onsuccess = () => {
sqliteDatabases.value = (getAllRequest.result as string[]).filter(
key => typeof key === 'string'
);
db.close();
resolve();
};
getAllRequest.onerror = () => {
db.close();
resolve();
};
};
request.onupgradeneeded = () => {
// Database doesn't exist or is being created, just close it
request.transaction?.abort();
resolve();
};
});
};
const saveAsDefaultDb = async () => {
const { alertController, toastController } = await import('@ionic/vue');
const alert = await alertController.create({
header: 'Save as Default',
message: 'This will save the current database as "default.sql" in jeepSqliteStore. This can be used as a backup or starting point. Continue?',
buttons: [
{
text: 'Cancel',
role: 'cancel'
},
{
text: 'Save',
handler: async () => {
try {
// First, ensure the current database is saved to store
const { saveDb } = await getSQLite();
await saveDb();
// Now copy from pricebookPlatform to default.sql in jeepSqliteStore
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('jeepSqliteStore');
request.onerror = () => reject(new Error('Could not open jeepSqliteStore'));
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('databases')) {
db.close();
reject(new Error('databases object store not found'));
return;
}
const transaction = db.transaction(['databases'], 'readwrite');
const store = transaction.objectStore('databases');
// Get the current database data (jeep-sqlite adds "SQLite.db" suffix)
const getRequest = store.get('pricebookPlatformSQLite.db');
getRequest.onsuccess = () => {
if (!getRequest.result) {
db.close();
reject(new Error('pricebookPlatformSQLite.db not found'));
return;
}
// Save as default.sql
const putRequest = store.put(getRequest.result, 'default.sql');
putRequest.onsuccess = () => {
db.close();
resolve();
};
putRequest.onerror = () => {
db.close();
reject(new Error('Failed to save default.sql'));
};
};
getRequest.onerror = () => {
db.close();
reject(new Error('Failed to read pricebookPlatform'));
};
};
});
const toast = await toastController.create({
message: 'Database saved as "default.sql"',
duration: 2000,
color: 'success'
});
await toast.present();
// Refresh the SQLite databases list if picker is open
await loadSqliteDatabasesFromStore();
} catch (e: any) {
const toast = await toastController.create({
message: `Failed to save: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
};
const uploadDefaultDbToServer = async () => {
const { toastController, alertController } = await import('@ionic/vue');
try {
// Read default.sql from jeepSqliteStore
const binaryData = await new Promise<ArrayBuffer>((resolve, reject) => {
const request = indexedDB.open('jeepSqliteStore');
request.onerror = () => reject(new Error('Could not open jeepSqliteStore'));
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('databases')) {
db.close();
reject(new Error('databases object store not found'));
return;
}
const transaction = db.transaction(['databases'], 'readonly');
const store = transaction.objectStore('databases');
const getRequest = store.get('default.sql');
getRequest.onsuccess = () => {
if (!getRequest.result) {
db.close();
reject(new Error('default.sql not found. Please save a default database first using "Save as Default".'));
return;
}
db.close();
resolve(getRequest.result);
};
getRequest.onerror = () => {
db.close();
reject(new Error('Failed to read default.sql'));
};
};
});
// Confirm upload
const alert = await alertController.create({
header: 'Upload Default Database',
message: `This will upload the default database (${(binaryData.byteLength / 1024 / 1024).toFixed(2)} MB) to the server. This will replace any existing default database. Continue?`,
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Upload',
handler: async () => {
try {
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
// Create a File from the binary data
const file = new File([binaryData], 'default.db', { type: 'application/octet-stream' });
// Check if a default database record already exists for this org
const existing = await pb.collection('defaultData').getList(1, 1, {
filter: `org = '${activeOrg}'`
});
if (existing.items.length > 0) {
// Update existing record
await pb.collection('defaultData').update(existing.items[0].id, {
database: file
});
} else {
// Create new record
await pb.collection('defaultData').create({
org: activeOrg,
database: file
});
}
const toast = await toastController.create({
message: 'Default database uploaded to server successfully',
duration: 3000,
color: 'success'
});
await toast.present();
} catch (e: any) {
const toast = await toastController.create({
message: `Upload failed: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
} catch (e: any) {
const toast = await toastController.create({
message: `Failed to read database: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
};
const downloadDefaultDbFromServer = async () => {
const { toastController, alertController } = await import('@ionic/vue');
try {
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
// Fetch default database record for this org
const records = await pb.collection('defaultData').getList(1, 1, {
filter: `org = '${activeOrg}'`
});
if (records.items.length === 0) {
throw new Error('No default database found for this organization');
}
const record = records.items[0];
if (!record.database) {
throw new Error('Default database record has no file attached');
}
// Get timestamp for display
const timestamp = record.updated || record.created;
// Confirm download
const alert = await alertController.create({
header: 'Download Default Database',
message: `This will download the default database from the server (last updated: ${new Date(timestamp).toLocaleString()}) and replace your local database. You will need to reload the page. Continue?`,
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Download',
handler: async () => {
try {
// Download the database file
const fileUrl = pb.files.getURL(record, record.database);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error('Failed to download default database');
}
const arrayBuffer = await response.arrayBuffer();
console.log('[Editor] Downloaded default.db from server, size:', arrayBuffer.byteLength);
// Store the database in jeepSqliteStore (same pattern as confirmDeleteDatabase)
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('jeepSqliteStore');
request.onerror = () => reject(new Error('Could not open jeepSqliteStore'));
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains('databases')) {
db.close();
reject(new Error('databases object store not found'));
return;
}
const transaction = db.transaction(['databases'], 'readwrite');
const store = transaction.objectStore('databases');
// Delete the current database first
const deleteRequest = store.delete('pricebookPlatformSQLite.db');
deleteRequest.onsuccess = () => {
// Then save the new database
const putRequest = store.put(arrayBuffer, 'pricebookPlatformSQLite.db');
putRequest.onsuccess = () => {
db.close();
console.log('[Editor] Default database saved to IndexedDB');
resolve();
};
putRequest.onerror = () => {
db.close();
reject(new Error('Failed to save database'));
};
};
deleteRequest.onerror = () => {
db.close();
reject(new Error('Failed to delete current database'));
};
};
});
// Store timestamp in localStorage for sync
localStorage.setItem('defaultDbTimestamp', timestamp);
const toast = await toastController.create({
message: 'Default database downloaded. Reloading...',
duration: 2000,
color: 'success'
});
await toast.present();
// Reload after a short delay
setTimeout(() => {
window.location.reload();
}, 1500);
} catch (e: any) {
const toast = await toastController.create({
message: `Download failed: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
} catch (e: any) {
const toast = await toastController.create({
message: `Failed: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
};
const deleteSpecificDb = async (dbName: string) => {
const { alertController, toastController } = await import('@ionic/vue');
const alert = await alertController.create({
header: 'Delete Database',
message: `Are you sure you want to delete "${dbName}"? This cannot be undone.`,
buttons: [
{
text: 'Cancel',
role: 'cancel'
},
{
text: 'Delete',
role: 'destructive',
handler: async () => {
try {
await new Promise<void>((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(dbName);
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
deleteRequest.onblocked = () => {
console.warn(`Delete blocked for ${dbName}`);
resolve();
};
});
const toast = await toastController.create({
message: `Database "${dbName}" deleted`,
duration: 2000,
color: 'success'
});
await toast.present();
// Refresh the list
await refreshDatabaseList();
} catch (e: any) {
const toast = await toastController.create({
message: `Failed to delete: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
};
const copyReplayLog = async () => {
const log = {
timestamp: new Date().toISOString(),
summary: {
success: replayResults.value.success,
failed: replayResults.value.errors.length
},
errors: replayResults.value.errors.map(err => ({
id: err.id,
created: err.created,
book: err.book,
bookName: getBookName(err.book),
error: err.error,
changeset: err.changeset
}))
};
await navigator.clipboard.writeText(JSON.stringify(log, null, 2));
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'Replay log copied to clipboard',
duration: 1500,
color: 'success'
});
await toast.present();
};
const copyMessageJson = async (msg: any) => {
const json = formatJsonForEdit(msg);
await navigator.clipboard.writeText(json);
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'JSON copied to clipboard',
duration: 1500,
color: 'success'
});
await toast.present();
};
const copyAllJson = async () => {
const allData = changeMessages.value.map(msg => ({
id: msg.id,
book: msg.book,
status: msg.status,
created: msg.created,
changeset: msg.changeset
}));
await navigator.clipboard.writeText(JSON.stringify(allData, null, 2));
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'All JSON copied to clipboard',
duration: 1500,
color: 'success'
});
await toast.present();
};
const deleteChangesetLocally = async (id: string) => {
const { alertController, toastController } = await import('@ionic/vue');
const alert = await alertController.create({
header: 'Delete Changeset',
message: 'Are you sure you want to delete this changeset from the local database? This cannot be undone.',
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Delete',
role: 'destructive',
handler: async () => {
try {
const sql = await getSQLite();
// Delete from changeMessages table
await sql.dbConn.execute(`DELETE FROM changeMessages WHERE id = '${id}'`);
await sql.saveDb();
// Remove from the local array
changeMessages.value = changeMessages.value.filter(msg => msg.id !== id);
const toast = await toastController.create({
message: 'Changeset deleted locally',
duration: 2000,
color: 'success'
});
await toast.present();
} catch (e: any) {
console.error('[Editor] Failed to delete changeset:', e);
const toast = await toastController.create({
message: `Failed to delete: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
};
const downloadAndViewAllChanges = async () => {
showDownloadedChangesModal.value = true;
downloadedChangesLoading.value = true;
downloadedChangesError.value = "";
downloadedChanges.value = [];
try {
const { pb } = await getApi();
// Get all pricebook changes from the API
const allChanges = await pb.collection('pricebookChanges').getFullList({
sort: 'created'
});
downloadedChanges.value = allChanges;
console.log(`[Editor] Downloaded ${allChanges.length} changesets from API`);
} catch (e: any) {
console.error('[Editor] Failed to download changes:', e);
downloadedChangesError.value = `Failed to download changes: ${e.message}`;
} finally {
downloadedChangesLoading.value = false;
}
};
const copyAllDownloadedChanges = async () => {
const { toastController } = await import('@ionic/vue');
try {
const jsonStr = JSON.stringify(downloadedChanges.value, null, 2);
await navigator.clipboard.writeText(jsonStr);
const toast = await toastController.create({
message: 'Copied all changes to clipboard',
duration: 2000,
color: 'success'
});
await toast.present();
} catch (e: any) {
const toast = await toastController.create({
message: `Failed to copy: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
};
const showCombineChangesDialog = async () => {
const { alertController, toastController } = await import('@ionic/vue');
if (changeMessages.value.length === 0) {
const toast = await toastController.create({
message: 'No changes to combine',
duration: 2000,
color: 'warning'
});
await toast.present();
return;
}
try {
// Get available books from the API
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
const books = await pb.collection('books').getFullList({
filter: `org = '${activeOrg}'`,
sort: 'name'
});
if (books.length === 0) {
const toast = await toastController.create({
message: 'No books found. Create a book first.',
duration: 3000,
color: 'warning'
});
await toast.present();
return;
}
// Build radio options for book selection
const bookInputs = books.map((book, index) => ({
name: 'book',
type: 'radio' as const,
label: `${book.name} (${book.refId})`,
value: book.id,
checked: index === 0
}));
const alert = await alertController.create({
header: 'Combine Changes',
message: `This will combine ${changeMessages.value.length} change(s) into a single changeset. Select the target book:`,
inputs: bookInputs,
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Combine & Create',
handler: async (selectedBookId) => {
if (!selectedBookId) {
const toast = await toastController.create({
message: 'Please select a book',
duration: 2000,
color: 'warning'
});
await toast.present();
return false;
}
try {
const BATCH_SIZE = 650;
// Sort messages by created date (oldest first) and combine all changesets
const sortedMessages = [...changeMessages.value].sort((a, b) =>
new Date(a.created).getTime() - new Date(b.created).getTime()
);
const allOperations: any[] = [];
for (const msg of sortedMessages) {
if (msg.changeset && Array.isArray(msg.changeset)) {
allOperations.push(...msg.changeset);
}
}
console.log('[Editor] Total operations to combine:', allOperations.length);
// Split into batches of BATCH_SIZE
const batches: any[][] = [];
for (let i = 0; i < allOperations.length; i += BATCH_SIZE) {
batches.push(allOperations.slice(i, i + BATCH_SIZE));
}
console.log('[Editor] Creating', batches.length, 'batch(es) of up to', BATCH_SIZE, 'operations each');
// Create a pricebookChanges record for each batch
let createdCount = 0;
const timestamp = Date.now();
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const refId = `combined-${timestamp}-${i + 1}`;
console.log(`[Editor] Creating batch ${i + 1}/${batches.length} with ${batch.length} operations (refId: ${refId})`);
await pb.collection('pricebookChanges').create({
book: selectedBookId,
org: activeOrg,
refId: refId,
changeset: batch
});
createdCount++;
}
console.log('[Editor] Created', createdCount, 'changeset record(s)');
const selectedBook = books.find(b => b.id === selectedBookId);
const toast = await toastController.create({
message: `Created ${createdCount} changeset(s) with ${allOperations.length} total operations for "${selectedBook?.name || selectedBookId}"`,
duration: 4000,
color: 'success'
});
await toast.present();
} catch (e: any) {
console.error('[Editor] Failed to create combined change:', e);
// Log detailed error info from PocketBase
if (e.data) {
console.error('[Editor] PocketBase error data:', e.data);
}
if (e.response) {
console.error('[Editor] PocketBase response:', e.response);
}
const toast = await toastController.create({
message: `Failed to combine changes: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
} catch (e: any) {
console.error('[Editor] Failed to load books:', e);
const toast = await toastController.create({
message: `Failed to load books: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
};
const pushChangesToServer = async () => {
const { alertController, toastController } = await import('@ionic/vue');
try {
// Get the last sync time from the database
const sql = await getSQLite();
const lastSyncResult = await sql.dbConn.query(
"SELECT value FROM appStatus WHERE key = 'lastSync'"
);
const lastSyncTime = lastSyncResult.values?.[0]?.value || '1970-01-01T00:00:00.000Z';
const lastSyncDate = new Date(lastSyncTime);
console.log('[Editor] Last sync time:', lastSyncTime);
// Filter changes that are newer than the last sync
const newChanges = changeMessages.value.filter(msg => {
const msgDate = new Date(msg.created);
return msgDate > lastSyncDate;
});
if (newChanges.length === 0) {
const toast = await toastController.create({
message: `No new changes since last sync (${lastSyncDate.toLocaleString()})`,
duration: 3000,
color: 'warning'
});
await toast.present();
return;
}
// Get available books from the API
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
const books = await pb.collection('books').getFullList({
filter: `org = '${activeOrg}'`,
sort: 'name'
});
if (books.length === 0) {
const toast = await toastController.create({
message: 'No books found. Create a book first.',
duration: 3000,
color: 'warning'
});
await toast.present();
return;
}
// Build radio options for book selection
const bookInputs = books.map((book, index) => ({
name: 'book',
type: 'radio' as const,
label: `${book.name} (${book.refId})`,
value: book.id,
checked: index === 0
}));
const alert = await alertController.create({
header: 'Push Changes to Server',
message: `Found ${newChanges.length} change(s) newer than last sync (${lastSyncDate.toLocaleString()}). Select the target book:`,
inputs: bookInputs,
buttons: [
{ text: 'Cancel', role: 'cancel' },
{
text: 'Push New Changes',
handler: async (selectedBookId) => {
if (!selectedBookId) {
const toast = await toastController.create({
message: 'Please select a book',
duration: 2000,
color: 'warning'
});
await toast.present();
return false;
}
try {
// Sort messages by created date (oldest first)
const sortedMessages = [...newChanges].sort((a, b) =>
new Date(a.created).getTime() - new Date(b.created).getTime()
);
console.log('[Editor] Pushing', sortedMessages.length, 'new change(s) to server');
let pushedCount = 0;
const timestamp = Date.now();
for (let i = 0; i < sortedMessages.length; i++) {
const msg = sortedMessages[i];
const refId = `push-${timestamp}-${i + 1}`;
if (!msg.changeset || !Array.isArray(msg.changeset) || msg.changeset.length === 0) {
console.log(`[Editor] Skipping empty changeset at index ${i}`);
continue;
}
console.log(`[Editor] Pushing change ${i + 1}/${sortedMessages.length} with ${msg.changeset.length} operations`);
await pb.collection('pricebookChanges').create({
book: selectedBookId,
org: activeOrg,
refId: refId,
changeset: msg.changeset
});
pushedCount++;
}
console.log('[Editor] Pushed', pushedCount, 'changeset(s) to server');
const selectedBook = books.find(b => b.id === selectedBookId);
const toast = await toastController.create({
message: `Pushed ${pushedCount} changeset(s) to "${selectedBook?.name || selectedBookId}"`,
duration: 4000,
color: 'success'
});
await toast.present();
} catch (e: any) {
console.error('[Editor] Failed to push changes:', e);
if (e.data) {
console.error('[Editor] PocketBase error data:', e.data);
}
const toast = await toastController.create({
message: `Failed to push changes: ${e.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
}
}
}
]
});
await alert.present();
} catch (e: any) {
console.error('[Editor] Failed to load books:', e);
const toast = await toastController.create({
message: `Failed: ${e.message}`,
duration: 3000,
color: 'danger'
});
await toast.present();
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'success': return 'success';
case 'error': return 'danger';
case 'pending': return 'warning';
default: return 'medium';
}
};
const onTableSelect = () => {
if (selectedTable.value) {
tableName.value = selectedTable.value;
}
};
const clearResults = () => {
tableName.value = "";
selectedTable.value = "";
results.value = null;
error.value = "";
lastQuery.value = "";
resultsFilter.value = "";
};
const onCreateCollectionSelect = async () => {
if (createSelectedCollection.value) {
// Define fields and relations based on collection type
const collectionSchemas: any = {
menus: {
fields: ['name', 'refId'],
relations: {
book: { type: 'single', collection: 'books' },
books: { type: 'multi', collection: 'books' }
}
},
offers: {
fields: ['name', 'refId', 'multiplier'],
relations: {
book: { type: 'single', collection: 'books' },
menus: { type: 'multi', collection: 'menus' },
costsTime: { type: 'multi', collection: 'costsTime' },
costsMaterial: { type: 'multi', collection: 'costsMaterial' }
}
},
menuTiers: {
fields: ['refId'],
relations: {
book: { type: 'single', collection: 'books' },
menus: { type: 'multi', collection: 'menus' },
offers: { type: 'multi', collection: 'offers' },
tiers: { type: 'multi', collection: 'tiers' },
menuCopy: { type: 'multi', collection: 'menuCopy' },
contentItems: { type: 'multi', collection: 'contentItems' }
}
},
contentItems: {
fields: ['name', 'refId', 'content'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
menuCopy: {
fields: ['name', 'refId'],
relations: {
book: { type: 'single', collection: 'books' },
contentItems: { type: 'multi', collection: 'contentItems' }
}
},
problems: {
fields: ['name', 'refId', 'description'],
relations: {
book: { type: 'single', collection: 'books' },
menus: { type: 'multi', collection: 'menus' },
problemTags: { type: 'multi', collection: 'problemTags' }
}
},
problemTags: {
fields: ['name', 'refId'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
checklists: {
fields: ['name', 'refId'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
costsMaterial: {
fields: ['name', 'refId', 'quantity'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
costsTime: {
fields: ['name', 'refId', 'hours'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
tierSets: {
fields: ['name', 'refId'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
tiers: {
fields: ['name', 'refId', 'rank'],
relations: {
book: { type: 'single', collection: 'books' },
tierSets: { type: 'multi', collection: 'tierSets' }
}
},
formulas: {
fields: ['name', 'refId', 'formula'],
relations: {
book: { type: 'single', collection: 'books' }
}
},
books: {
fields: ['name', 'refId'],
relations: {
parent: { type: 'single', collection: 'books' },
dependencies: { type: 'multi', collection: 'books' }
}
}
};
const schema = collectionSchemas[createSelectedCollection.value] || { fields: ['name', 'refId'], relations: {} };
// Create field definitions
createFields.value = schema.fields.map((fieldName: string) => {
let type = 'text';
let isCurrency = false;
if (['quantity', 'multiplier', 'hours', 'rank'].includes(fieldName)) {
type = 'number';
}
// Mark currency fields
if (fieldName === 'quantity') {
isCurrency = true;
}
return { name: fieldName, type, isRelation: false, isCurrency };
});
// Add relation fields
for (const [relationName, relationConfig] of Object.entries(schema.relations)) {
createFields.value.push({
name: relationName,
type: 'relation',
isRelation: true,
relationType: (relationConfig as any).type,
relationCollection: (relationConfig as any).collection
});
}
// Initialize empty data and relations
createData.value = {};
editedRelations.value = {};
availableRelations.value = {};
// Get the default book for this org (if book field exists)
let defaultBookId = null;
if (schema.relations.book) {
try {
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (activeOrg) {
const books = await pb.collection('books').getList(1, 1, {
filter: `org = '${activeOrg}'`,
sort: '-created'
});
if (books.items.length > 0) {
defaultBookId = books.items[0].id;
}
}
} catch (error) {
console.warn('Could not get default book:', error);
}
}
for (const field of createFields.value) {
if (field.isRelation) {
// Set default book if this is the book field
if (field.name === 'book' && defaultBookId) {
editedRelations.value[field.name] = defaultBookId;
} else {
editedRelations.value[field.name] = field.relationType === 'single' ? null : [];
}
// Load available options for this relation
availableRelations.value[field.name] = await loadAvailableRelations(field.relationCollection);
} else {
createData.value[field.name] = '';
}
}
}
};
const cancelCreate = () => {
createSelectedCollection.value = "";
createFields.value = [];
createData.value = {};
goBackToBrowse();
};
const saveNewRecord = async () => {
try {
loading.value = true;
// Get API and organization
const { pb } = await getApi();
const activeOrg = pb.authStore.record?.expand?.activeOrg?.id || pb.authStore.record?.activeOrg;
if (!activeOrg) {
throw new Error('No active organization found');
}
// Get the default book for this org
const books = await pb.collection('books').getList(1, 1, {
filter: `org = '${activeOrg}'`,
sort: '-created'
});
let bookId;
if (books.items.length > 0) {
bookId = books.items[0].id;
} else {
// Create a default book if none exists
const newBook = await pb.collection('books').create({
name: 'Editor Changes',
refId: 'editorChanges',
org: activeOrg
});
bookId = newBook.id;
}
// Prepare data with refs for relations
const data: any = {};
// Process regular fields and handle currency conversion
for (const field of createFields.value) {
if (!field.isRelation && createData.value[field.name] !== undefined) {
let value = createData.value[field.name];
// Convert currency fields from user's currency to base currency
if (field.isCurrency && value !== '') {
// Parse the value as a number first
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
try {
value = convertToBaseCurrency(numValue);
} catch (error) {
console.warn(`Failed to convert currency for ${field.name}:`, error);
// Keep the original value if conversion fails
value = numValue;
}
}
} else if (field.type === 'number' && value !== '') {
// Convert other number fields to proper numbers
value = parseFloat(value);
}
data[field.name] = value;
}
}
// Process relations into refs structure (excluding book field)
const refs: any[] = [];
for (const [fieldName, value] of Object.entries(editedRelations.value)) {
// Skip the book field - it's handled separately as a direct field
if (fieldName === 'book') {
continue;
}
if (value !== null && value !== undefined) {
// Get the actual collection name from the field configuration
const field = createFields.value.find(f => f.name === fieldName);
const collectionName = field?.relationCollection || fieldName;
// Handle single relations
if (!Array.isArray(value)) {
// For single relations, we need to get the refId
const relationOption = availableRelations.value[fieldName]?.find((r: any) => r.value === value);
if (relationOption?.refId) {
refs.push({
collection: collectionName,
refIds: [relationOption.refId]
});
}
}
// Handle multi relations
else if (value.length > 0) {
const refIds = value.map((id: string) => {
const relation = availableRelations.value[fieldName]?.find((r: any) => r.value === id);
return relation?.refId || id;
});
refs.push({
collection: collectionName,
refIds: refIds
});
}
}
}
// Add refs to data if there are any
if (refs.length > 0) {
data.refs = refs;
}
// Prepare changeset
const changeset = [{
collection: createSelectedCollection.value,
operation: 'create',
data: data
}];
// Submit the changeset
const changesetRecord = await pb.collection('pricebookChanges').create({
book: bookId,
org: activeOrg,
changeset: changeset
});
console.log('New record changeset submitted:', changesetRecord);
// Show success message
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: 'Record created successfully. Syncing database...',
duration: 2000,
color: 'success'
});
await toast.present();
// Sync the database
const { syncDatabase } = await import('@/dataAccess/getDb');
const syncResult = await syncDatabase();
if (syncResult.success) {
const syncToast = await toastController.create({
message: 'Database synced successfully',
duration: 2000,
color: 'success'
});
await syncToast.present();
}
// Clear create mode and go back
cancelCreate();
} catch (error: any) {
console.error('Error creating record:', error);
const { toastController } = await import('@ionic/vue');
const toast = await toastController.create({
message: `Failed to create record: ${error.message}`,
duration: 4000,
color: 'danger'
});
await toast.present();
} finally {
loading.value = false;
}
};
const handleRouteChange = async () => {
const id = route.params.id as string;
const collection = route.params.collection as string;
const path = route.path;
if (path === '/editor/create') {
isCreateMode.value = true;
isIdMode.value = false;
createSelectedCollection.value = "";
createFields.value = [];
createData.value = {};
} else if (collection && id) {
// Route with collection specified: /editor/:collection/:id
searchId.value = id;
isIdMode.value = true;
isCreateMode.value = false;
selectedTable.value = collection;
await searchTableForId(collection, id);
} else if (id && id !== 'create') {
searchId.value = id;
isIdMode.value = true;
isCreateMode.value = false;
searchAllTablesForId(id);
} else {
resetToInitialState();
}
};
const searchTableForId = async (table: string, id: string) => {
loading.value = true;
error.value = "";
// Clear single offer tech handbook when switching records
singleOfferTechHandbook.value = null;
singleOfferTechHandbookError.value = "";
try {
const sql = await getSQLite();
const query = `SELECT * FROM ${table} WHERE id = '${id}'`;
const result = await sql.dbConn.query(query);
if (result.values && result.values.length > 0) {
results.value = result;
selectedTable.value = table;
// If viewing an offer, also load its tech handbook
if (table === 'offers') {
loadSingleOfferTechHandbook(id);
}
} else {
error.value = `No record found with ID: ${id} in table: ${table}`;
results.value = null;
}
} catch (e: any) {
error.value = e.message || 'Query failed';
results.value = null;
} finally {
loading.value = false;
}
};
// Watch for route changes
watch(() => [route.params.id, route.params.collection], handleRouteChange, { immediate: true });
// Watch for dark mode toggles to activate super secret mode
watch(() => preferences.darkModeToggleCount, (newCount) => {
// Only count toggles while in secret mode and viewing change history in JSON mode
if (sessionStore.secretMode && showChangeHistory.value && changeHistoryViewMode.value === 'json') {
const togglesSinceLastCheck = newCount - lastToggleCount.value;
if (togglesSinceLastCheck >= 4) {
superSecretMode.value = true;
preferences.resetDarkModeToggleCount();
lastToggleCount.value = 0;
}
} else {
// Reset counter when not in the right mode
lastToggleCount.value = newCount;
}
});
// Reset super secret mode when leaving change history or JSON view
watch([showChangeHistory, changeHistoryViewMode], () => {
if (!showChangeHistory.value || changeHistoryViewMode.value !== 'json') {
superSecretMode.value = false;
lastToggleCount.value = preferences.darkModeToggleCount;
}
});
onMounted(async () => {
handleRouteChange();
// Check for ?secretmode=true URL parameter to enable super secret mode
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('secretmode') === 'true') {
// Enable the same state as toggling dark mode 4x in secret mode
showChangeHistory.value = true;
changeHistoryViewMode.value = 'json';
superSecretMode.value = true;
await loadChangeMessages();
}
// Load books cache
try {
const { dbConn } = await getSQLite();
const booksResult = await dbConn.query('SELECT id, name FROM books');
if (booksResult.values) {
booksCache.value = booksResult.values;
}
} catch (error) {
console.warn('Failed to load books cache:', error);
}
});
</script>
<style scoped>
.lookup-row {
margin-bottom: 12px;
align-items: center;
}
.lookup-input {
--padding-start: 12px;
--padding-end: 12px;
border: 1px solid #ccc;
border-radius: 4px;
min-width: 250px;
}
.results-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.record-card {
margin: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.field-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
padding: 4px 0;
border-bottom: 1px solid #f0f0f0;
}
.field-row:last-child {
border-bottom: none;
}
.field-row strong {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.field-value {
font-family: monospace;
font-size: 14px;
color: #333;
word-break: break-word;
line-height: 1.4;
}
.relation-data {
margin-top: 8px;
}
.related-item {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 8px;
margin-bottom: 8px;
}
.related-item:last-child {
margin-bottom: 0;
}
.related-header {
font-weight: bold;
color: #495057;
font-size: 12px;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.related-field {
display: flex;
flex-direction: column;
margin-bottom: 4px;
padding-bottom: 4px;
border-bottom: 1px solid #dee2e6;
}
.related-field:last-child {
border-bottom: none;
margin-bottom: 0;
}
.related-key {
font-size: 11px;
color: #6c757d;
font-weight: bold;
margin-bottom: 2px;
}
.related-value {
font-family: monospace;
font-size: 12px;
color: #212529;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
.table-select {
width: 100%;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: white;
font-size: 14px;
color: #333;
}
.id-link {
color: #3880ff;
text-decoration: underline;
cursor: pointer;
font-family: monospace;
}
.id-link:hover {
color: #0066cc;
text-decoration: none;
}
.edit-input {
margin-top: 4px;
font-family: monospace;
width: 100%;
min-width: 300px;
}
.edit-input ion-input,
.edit-input ion-textarea {
--padding-start: 12px;
--padding-end: 12px;
--padding-top: 8px;
--padding-bottom: 8px;
font-family: monospace;
}
.currency-hint {
display: block;
margin-top: 4px;
color: #666;
font-style: italic;
font-size: 12px;
}
.relation-edit {
margin-top: 8px;
}
.relation-edit-header {
font-weight: bold;
margin-bottom: 8px;
color: #495057;
}
.current-relations {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.relation-chip.removable {
cursor: pointer;
background-color: #e9ecef;
transition: background-color 0.2s;
}
.relation-chip.removable:hover {
background-color: #f8d7da;
}
.remove-icon {
margin-left: 4px;
font-size: 14px;
}
.add-relations {
margin-top: 8px;
}
.relation-add-button {
width: 100%;
margin-top: 4px;
}
.relation-modal-content {
--padding-bottom: 0;
}
.relation-search-input {
margin-bottom: 16px;
}
.relation-options {
overflow-y: auto;
padding-bottom: 16px;
}
.relation-option-item {
--padding-start: 16px;
--padding-end: 16px;
}
.no-results {
text-align: center;
color: #666;
padding: 20px;
font-style: italic;
}
.relation-selector-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 12px 16px;
width: 100%;
}
.book-info {
padding: 12px;
background-color: #f0f4f8;
border-radius: 8px;
margin-bottom: 16px;
font-size: 15px;
}
.changeset-card {
margin-bottom: 12px;
}
.changeset-data {
margin-top: 8px;
}
.data-field {
display: flex;
flex-direction: column;
margin-bottom: 8px;
padding: 8px;
background-color: #f8f9fa;
border-left: 3px solid #007bff;
border-radius: 4px;
}
.data-field strong {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.data-value {
font-family: monospace;
font-size: 14px;
color: #333;
word-break: break-word;
line-height: 1.4;
}
.changeset-modal-content {
--padding-bottom: 0;
}
.changeset-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 12px 16px;
width: 100%;
}
.create-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.filter-container {
margin-bottom: 16px;
}
.filter-container ion-item {
--background: #f8f9fa;
--border-radius: 8px;
--padding-start: 12px;
--padding-end: 12px;
}
.warning-card {
background-color: #fff5f5;
border: 1px solid #feb2b2;
}
.warning-text {
color: #e53e3e;
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-weight: 600;
}
@media (min-width: 768px) {
.field-row {
flex-direction: row;
align-items: flex-start;
}
.field-row strong {
min-width: 120px;
margin-bottom: 0;
margin-right: 12px;
margin-top: 8px;
}
.field-value {
flex: 1;
min-width: 0;
}
}
/* Change messages */
.change-message-card {
margin-bottom: 12px;
}
.change-message-card ion-card-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.changeset-summary ul {
margin: 8px 0 0 16px;
padding: 0;
}
.changeset-summary li {
margin-bottom: 4px;
font-family: monospace;
font-size: 13px;
}
.error-message {
background-color: #fee2e2;
color: #dc2626;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #666;
}
/* Change History Styles */
.change-history-section {
margin-top: 16px;
}
.view-mode-segment {
margin-top: 12px;
max-width: 300px;
}
.friendly-view {
display: flex;
flex-direction: column;
gap: 12px;
}
.change-header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.change-meta {
margin-top: 8px;
font-size: 13px;
color: #666;
}
.meta-item {
margin-right: 16px;
}
.changeset-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.change-item {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
border-left: 4px solid #6c757d;
}
.change-item:has(.change-operation.create) {
border-left-color: #28a745;
}
.change-item:has(.change-operation.update) {
border-left-color: #007bff;
}
.change-item:has(.change-operation.delete) {
border-left-color: #dc3545;
}
.change-operation {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.op-badge {
font-size: 11px;
font-weight: bold;
padding: 2px 8px;
border-radius: 4px;
background: #6c757d;
color: white;
}
.op-badge.create, .change-operation.create .op-badge {
background: #28a745;
}
.op-badge.update, .change-operation.update .op-badge {
background: #007bff;
}
.op-badge.delete, .change-operation.delete .op-badge {
background: #dc3545;
}
.collection-name {
font-weight: 600;
color: #333;
}
.ref-id {
font-family: monospace;
font-size: 12px;
color: #666;
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
}
.change-data-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.data-preview-field {
font-size: 12px;
background: white;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #dee2e6;
}
.field-key {
color: #666;
margin-right: 4px;
}
.field-value-preview {
font-family: monospace;
color: #333;
}
/* JSON View Styles */
.json-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.json-controls {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.json-message-block {
border: 1px solid #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.json-message-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.json-date {
font-size: 13px;
color: #666;
flex: 1;
}
.json-editor {
width: 100%;
min-height: 200px;
padding: 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
line-height: 1.5;
border: none;
resize: vertical;
background: #1e1e1e;
color: #d4d4d4;
}
.json-editor:focus {
outline: none;
box-shadow: inset 0 0 0 2px #007bff;
}
.json-error {
padding: 8px 12px;
background: #fee2e2;
color: #dc2626;
font-size: 12px;
font-family: monospace;
}
/* Change Detail Modal */
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #dee2e6;
}
.detail-data h4 {
margin: 0 0 12px 0;
color: #333;
}
.detail-field {
margin-bottom: 12px;
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
}
.detail-field strong {
display: block;
font-size: 12px;
color: #666;
text-transform: uppercase;
margin-bottom: 4px;
}
.detail-value {
font-family: monospace;
font-size: 13px;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
/* Save Confirmation Dialog */
.confirm-dialog-content {
text-align: center;
padding: 20px;
}
.confirm-icon {
font-size: 64px;
margin-bottom: 16px;
}
.confirm-dialog-content h3 {
margin: 0 0 12px 0;
color: #333;
}
.confirm-dialog-content p {
color: #666;
margin-bottom: 12px;
}
.confirm-dialog-content .db-filename {
font-size: 14px;
color: #333;
margin-bottom: 16px;
}
.confirm-dialog-content .db-filename code {
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
color: #d32f2f;
}
.confirm-note {
background: #fff8e1;
border: 1px solid #ffcc02;
border-radius: 8px;
padding: 12px;
font-size: 13px;
text-align: left;
}
.confirm-details {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
margin-top: 16px;
text-align: left;
font-size: 13px;
font-family: monospace;
}
.confirm-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding: 12px 16px;
width: 100%;
}
/* Replay Dialog Styles */
.replay-steps {
text-align: left;
margin: 16px 0;
padding-left: 24px;
}
.replay-steps li {
margin-bottom: 8px;
color: #333;
}
.replay-warning {
background: #fee2e2;
border: 1px solid #fca5a5;
border-radius: 8px;
padding: 12px;
margin-top: 16px;
text-align: left;
font-size: 13px;
display: flex;
gap: 8px;
align-items: flex-start;
}
.replay-warning ion-icon {
font-size: 20px;
flex-shrink: 0;
margin-top: 2px;
}
/* Replay Progress Overlay */
.replay-progress-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
}
.replay-progress-card {
width: 90%;
max-width: 400px;
text-align: center;
}
.replay-progress-card ion-spinner {
font-size: 48px;
margin-bottom: 16px;
}
.replay-progress-card h3 {
margin: 0 0 8px 0;
color: #333;
}
.replay-progress-card p {
color: #666;
margin: 8px 0;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin: 16px 0;
}
.progress-fill {
height: 100%;
background: #3880ff;
transition: width 0.3s ease;
}
.progress-count {
font-family: monospace;
font-size: 14px;
color: #666;
}
/* Replay Results Dialog */
.replay-results-summary {
display: flex;
justify-content: center;
gap: 32px;
margin-bottom: 24px;
padding: 16px;
background: #f8f9fa;
border-radius: 12px;
}
.result-stat {
text-align: center;
}
.stat-number {
display: block;
font-size: 36px;
font-weight: bold;
line-height: 1;
}
.stat-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.result-stat.success .stat-number {
color: #28a745;
}
.result-stat.error .stat-number {
color: #dc3545;
}
.replay-errors-section h3 {
margin: 0 0 8px 0;
color: #333;
}
.errors-hint {
color: #666;
font-size: 14px;
margin-bottom: 16px;
}
.replay-error-item {
background: #fff;
border: 1px solid #fee2e2;
border-left: 4px solid #dc3545;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.error-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.error-id {
font-family: monospace;
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
}
.error-date {
color: #666;
}
.error-book {
color: #666;
}
.error-message {
display: flex;
align-items: flex-start;
gap: 8px;
background: #fee2e2;
color: #dc3545;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 8px;
}
.error-message ion-icon {
flex-shrink: 0;
font-size: 18px;
margin-top: 1px;
}
.error-changeset {
margin-top: 8px;
}
.error-changeset strong {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.error-changeset pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 6px;
font-size: 11px;
overflow-x: auto;
max-height: 200px;
margin: 0;
}
/* Super secret mode / Delete database styles */
.skull-icon {
color: #333;
}
.pull-info {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
padding: 12px;
margin-top: 16px;
text-align: left;
font-size: 13px;
}
.pull-info p {
margin: 0 0 8px 0;
color: #1565c0;
font-weight: 500;
}
.pull-info ul {
margin: 0;
padding-left: 20px;
color: #333;
}
.pull-info li {
margin-bottom: 4px;
}
/* DB File Picker Styles */
.db-picker-content {
padding: 8px;
}
.db-picker-info {
color: #666;
font-size: 14px;
margin-bottom: 20px;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
}
.db-loading {
text-align: center;
padding: 40px;
}
.db-loading ion-spinner {
font-size: 32px;
margin-bottom: 12px;
}
.db-loading p {
color: #666;
}
.db-empty {
text-align: center;
padding: 40px;
color: #666;
}
.db-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.db-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
}
.db-item-info {
display: flex;
align-items: center;
gap: 12px;
}
.db-icon {
font-size: 24px;
color: #666;
}
.db-details {
display: flex;
flex-direction: column;
}
.db-name {
font-weight: 600;
font-family: monospace;
font-size: 14px;
color: #333;
}
.db-version {
font-size: 12px;
color: #666;
}
.db-item-actions {
display: flex;
gap: 8px;
}
.db-picker-actions {
margin-top: 24px;
display: flex;
justify-content: center;
}
.db-section {
margin-bottom: 32px;
}
.db-section-title {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.db-section-info {
color: #666;
font-size: 13px;
margin-bottom: 16px;
}
.db-empty-inline {
padding: 16px;
text-align: center;
color: #999;
font-style: italic;
background: #fafafa;
border-radius: 8px;
}
.db-empty-inline p {
margin: 0;
}
.sqlite-item {
border-left: 4px solid #3880ff;
}
.sqlite-icon {
color: #3880ff;
}
.db-type {
font-size: 11px;
color: #3880ff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Download All Changes Modal */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
gap: 16px;
}
.error-state {
color: var(--ion-color-danger);
}
.changes-summary {
margin-bottom: 16px;
font-size: 14px;
}
.downloaded-changes-controls {
margin-bottom: 16px;
}
.downloaded-changes-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 8px;
overflow: auto;
max-height: 60vh;
font-size: 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
white-space: pre-wrap;
word-break: break-word;
}
/* Tech Handbook Browser */
.tech-handbook-section {
margin-top: 16px;
margin-bottom: 16px;
}
.tech-handbook-summary {
margin-bottom: 16px;
font-size: 14px;
}
.tech-handbook-offer {
margin-bottom: 24px;
padding: 16px;
background: var(--ion-color-light);
border-radius: 8px;
}
.tech-handbook-offer .offer-title {
margin: 0 0 12px 0;
font-size: 18px;
color: var(--ion-color-primary);
border-bottom: 1px solid var(--ion-color-medium);
padding-bottom: 8px;
}
.tech-handbook-items {
margin: 0;
padding-left: 20px;
}
.tech-handbook-item {
margin-bottom: 8px;
line-height: 1.5;
}
.tech-handbook-item strong {
color: var(--ion-color-dark);
}
.empty-state {
text-align: center;
padding: 20px;
color: var(--ion-color-medium);
}
</style>