ItemizeItPro — ruelle.services Estate Inventory

Owner: Gene Ruelle · Printed: July 4, 2026

ItemizeItPro
The Real Inventory Solution
--:-- --
Loading…
ItemizeItPro - Document Your Possessions
V. 5 · ruelle.services
ITEMIZEITPRO V.5
GR
Gene Ruelle
Premium Member
Online
Main
Dashboard
Reports & Analytics
Inventory Modes
Standard Inventory
PPM Mode PREMIUM
Full Benefits Premium Mode
Management
Categories
Documents & Files
Key Word Search
Settings
Settings
Admin Console
Help & Support
12 of 50 items24%
Press N to add a new item
`; const win=window.open('','_blank'); if(!win){showToast('Please allow pop-ups to print labels','error');return} win.document.open(); win.document.write(doc); win.document.close(); showToast('Label sheet ready to print','success'); } /* ============================================ SETTINGS ============================================ */ function renderSettings(main){ const tab=AppState.settingsTab; main.innerHTML=`

Settings

${['account','preferences','subscription'].map(t=>``).join('')}
`; main.querySelectorAll('.settings-tab-btn').forEach(b=>b.addEventListener('click',()=>{ AppState.settingsTab=b.dataset.stab; renderSettings(main); })); const sc=document.getElementById('settings-content-ip'); if(tab==='account'){ sc.innerHTML=`

Profile Information


Change Password

`; document.getElementById('save-acct-ip').addEventListener('click',()=>showToast('Account settings saved','success')); } else if(tab==='preferences'){ sc.innerHTML=`

Preferences


Notifications

${[['Email Reminders',true],['Browser Notifications',false],['Monthly Summary',true]].map(([l,v])=>`
${l}
`).join('')}
Auto-save
`; sc.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('active'))); document.getElementById('save-prefs-ip').addEventListener('click',()=>showToast('Preferences saved','success')); } else if(tab==='subscription'){ sc.innerHTML=`
✦ PREMIUMAll Features Unlocked
${['Standard Inventory (Unlimited)','Personal Property Memorandum Mode','Photo Storage (500 photos)','PDF Report Generation','Attorney Share Portal','Priority Support'].map(f=>`
${f}
`).join('')}
Next billing: August 4, 2026 · $29.99/mo
`; document.getElementById('manage-sub-ip').addEventListener('click',()=>showToast('Redirecting to billing portal...','info')); document.getElementById('dl-invoices-ip').addEventListener('click',()=>showToast('Downloading invoices...','info')); } if(typeof lucide!=="undefined")lucide.createIcons(); } /* ============================================ ADMIN CONSOLE ============================================ */ // BACKEND HOOK: GET /api/admin/users -> [{ email, standardItems:[...], ppmItems:[...] }, ...] // In production, an admin console needs to see every tenant's data, not just the // signed-in user's. Swap the single-group fallback below for a real fetch once that // endpoint exists; items would carry (or resolve to) an ownerEmail so grouping "just works." function getAdminUserGroups(){ const allItems=[...AppState.standardItems.map(i=>({...i,mode:'standard'})),...AppState.ppmItems.map(i=>({...i,mode:'ppm'}))]; const fallbackEmail=AppState.userEmail||'Guest (Not Signed In)'; const groups={}; allItems.forEach(item=>{ const email=item.ownerEmail||fallbackEmail; if(!groups[email])groups[email]=[]; groups[email].push(item); }); return Object.entries(groups) .map(([email,items])=>({email,items})) .sort((a,b)=>a.email.localeCompare(b.email)); } // Approve/reject a pending Premium reference-code request from the Admin // Console (see PREMIUM ACTIVATION comment above renderAdmin/showUpgradePrompt // for the full flow and the real backend endpoints this would call instead). function approvePremiumRequest(code){ const req=AppState.premiumRequests.find(r=>r.code===code); if(!req)return; req.status='approved'; req.approvedAt=Date.now(); // Single-tenant demo: this browser IS the account being approved, so we // can flip isPremium directly. In production the backend would set // isPremium=true on req.ownerEmail's own row, not on whichever browser // happens to be viewing the admin console. if(req.ownerEmail===getPremiumOwnerKey())activatePremium(true); saveState(); showToast('Premium approved for '+req.ownerEmail,'success'); renderView(); } function rejectPremiumRequest(code){ const req=AppState.premiumRequests.find(r=>r.code===code); if(!req)return; req.status='rejected'; saveState(); showToast('Request '+code+' rejected','info'); renderView(); } function renderAdmin(main){ const userGroups=getAdminUserGroups(); const totalItems=userGroups.reduce((s,g)=>s+g.items.length,0); const totalPPM=userGroups.reduce((s,g)=>s+g.items.filter(i=>i.mode==='ppm').length,0); const totalStd=totalItems-totalPPM; const premiumPending=AppState.premiumRequests.filter(r=>r.status==='pending').sort((a,b)=>a.requestedAt-b.requestedAt); const premiumResolved=AppState.premiumRequests.filter(r=>r.status!=='pending').sort((a,b)=>(b.approvedAt||b.requestedAt)-(a.approvedAt||a.requestedAt)).slice(0,10); main.innerHTML=`

Admin Console

Admin Access Required in Production
${userGroups.length}
Total Users
${totalPPM}
PPM Items (All Users)
${totalStd}
Standard Items (All Users)

Pending Premium Activations

Match each code against your GoDaddy Payments notification email (same code, name, and $275 amount in the Notes field) before approving.

${premiumPending.length===0?`

No pending requests.

`:`
${premiumPending.map(r=>`
${r.code} ${r.ownerEmail} ${formatDateShort(r.requestedAt)}
`).join('')}
`} ${premiumResolved.length>0?`
Recent Decisions
${premiumResolved.map(r=>`
${r.code} — ${r.ownerEmail} ${r.status==='approved'?'Approved':'Rejected'}
`).join('')}
`:''}
${userGroups.map((group,gi)=>{ const gStd=group.items.filter(i=>i.mode==='standard').length; const gPpm=group.items.filter(i=>i.mode==='ppm').length; return`

${group.email}

${gStd} Standard ${gPpm} PPM
${group.items.length===0?``:group.items.map(item=>` `).join('')}
Item # Item Name Mode Category Bequeath To Modified Actions
No items for this user.
${item.itemNumber||'—'} ${item.name} ${item.mode==='ppm'?'PPM':'Standard'} ${item.category} ${item.bequeathTo&&item.bequeathTo[0]?item.bequeathTo[0].name:'—'} ${formatDate(item.updatedAt)}
`; }).join('')}
`; main.querySelectorAll('[data-action="admin-tbl-edit"]').forEach(b=>b.addEventListener('click',()=>openModal(b.dataset.mode,b.dataset.id))); main.querySelectorAll('[data-action="admin-tbl-del"]').forEach(b=>b.addEventListener('click',()=>showDeleteConfirm(b.dataset.mode,b.dataset.id,b))); main.querySelectorAll('[data-action="premium-approve"]').forEach(b=>b.addEventListener('click',()=>approvePremiumRequest(b.dataset.code))); main.querySelectorAll('[data-action="premium-reject"]').forEach(b=>b.addEventListener('click',()=>rejectPremiumRequest(b.dataset.code))); if(typeof lucide!=="undefined")lucide.createIcons(); } /* ============================================ PLACEHOLDER VIEW ============================================ */ function renderPlaceholder(main,view){ const titles={help:'Help & Support'}; main.innerHTML=`

${titles[view]||view}

This section is available in the full production build. Backend integration required.

`; document.getElementById('ph-back-ip').addEventListener('click',()=>navigate('dashboard')); if(typeof lucide!=="undefined")lucide.createIcons(); } /* ============================================ SEARCH KEY WORDS (TAGS) MANAGER ============================================ */ function getAllKeywordStats(){ const map={}; const record=(tag,mode)=>{ const key=tag.trim(); if(!key)return; if(!map[key])map[key]={tag:key,standard:0,ppm:0}; map[key][mode]++; }; AppState.standardItems.forEach(i=>(i.tags||[]).forEach(t=>record(t,'standard'))); AppState.ppmItems.forEach(i=>(i.tags||[]).forEach(t=>record(t,'ppm'))); return Object.values(map).sort((a,b)=>a.tag.localeCompare(b.tag)); } function renderKeywordsManager(main){ const stats=getAllKeywordStats(); main.innerHTML=`

Key Word Search

Every keyword tagged on your items, in one place. Click a keyword to find items, or rename/remove it everywhere it's used.

New key words appear here once you attach them to an item from the Add/Edit Item screen. Adding one here just gets it ready to use — pick it from the Tags field next time you add or edit an item.

`; renderKeywordList(stats); document.getElementById('kw-new-add-ip').addEventListener('click',()=>{ const input=document.getElementById('kw-new-input-ip'); const val=input.value.trim(); if(!val)return; if(!AppState.pendingKeywords)AppState.pendingKeywords=[]; if(!AppState.pendingKeywords.includes(val)&&!getAllKeywordStats().some(s=>s.tag.toLowerCase()===val.toLowerCase())){ AppState.pendingKeywords.push(val); } input.value=''; renderKeywordList(getAllKeywordStats()); showToast('Key word ready to use on items','success'); }); document.getElementById('kw-new-input-ip').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('kw-new-add-ip').click()}); if(typeof lucide!=="undefined")lucide.createIcons(); } function renderKeywordList(stats){ const wrap=document.getElementById('kw-list-ip'); const pending=(AppState.pendingKeywords||[]).filter(p=>!stats.some(s=>s.tag.toLowerCase()===p.toLowerCase())); if(stats.length===0&&pending.length===0){ wrap.innerHTML=`

No Search Key Words Yet

Tag items with key words like "heirloom" or "daily-use" from the Add/Edit Item screen, or add one above to get started.

`; if(typeof lucide!=="undefined")lucide.createIcons(); return; } wrap.innerHTML=`
${stats.map(s=>{ const total=s.standard+s.ppm; return `
${s.tag} ${total} item${total===1?'':'s'}${s.standard&&s.ppm?` (${s.standard} standard, ${s.ppm} PPM)`:''}
`; }).join('')} ${pending.map(p=>`
${p} Not used on any item yet
`).join('')}
`; wrap.querySelectorAll('[data-action="kw-search"]').forEach(b=>b.addEventListener('click',()=>{ const tag=b.dataset.tag; AppState.searchQuery=tag; const inStd=AppState.standardItems.some(i=>(i.tags||[]).includes(tag)); const target=inStd?'standard':'ppm'; if(target==='standard'&&!AppState.userEmail){showEmailGateModal('standard');return} navigate(target); AppState.searchQuery=tag; renderView(); })); wrap.querySelectorAll('[data-action="kw-delete"]').forEach(b=>b.addEventListener('click',()=>{ const row=b.closest('.kw-row'); row.innerHTML=`
Remove "${b.dataset.tag}" from all items? Cannot undo.
`; row.querySelector('[data-action="kw-delete-confirm"]').addEventListener('click',()=>{ deleteKeywordEverywhere(b.dataset.tag); }); row.querySelector('[data-action="kw-delete-cancel"]').addEventListener('click',()=>renderKeywordList(getAllKeywordStats())); })); wrap.querySelectorAll('[data-action="kw-pending-remove"]').forEach(b=>b.addEventListener('click',()=>{ AppState.pendingKeywords=(AppState.pendingKeywords||[]).filter(p=>p!==b.dataset.tag); renderKeywordList(getAllKeywordStats()); })); wrap.querySelectorAll('[data-action="kw-rename"]').forEach(b=>b.addEventListener('click',()=>{ const row=b.closest('.kw-row'); const oldTag=b.dataset.tag; row.innerHTML=`
`; const input=row.querySelector('#kw-rename-input-ip'); input.focus();input.select(); const confirm=()=>{ const newTag=input.value.trim(); if(newTag&&newTag!==oldTag)renameKeywordEverywhere(oldTag,newTag); else renderKeywordList(getAllKeywordStats()); }; row.querySelector('[data-action="kw-rename-confirm"]').addEventListener('click',confirm); input.addEventListener('keydown',e=>{if(e.key==='Enter')confirm();if(e.key==='Escape')renderKeywordList(getAllKeywordStats())}); row.querySelector('[data-action="kw-rename-cancel"]').addEventListener('click',()=>renderKeywordList(getAllKeywordStats())); })); if(typeof lucide!=="undefined")lucide.createIcons(); } function renameKeywordEverywhere(oldTag,newTag){ const rename=list=>list.forEach(i=>{ if(!i.tags)return; const idx=i.tags.indexOf(oldTag); if(idx!==-1){ if(!i.tags.includes(newTag))i.tags[idx]=newTag; else i.tags.splice(idx,1); i.updatedAt=Date.now(); } }); rename(AppState.standardItems); rename(AppState.ppmItems); if(AppState.pendingKeywords)AppState.pendingKeywords=AppState.pendingKeywords.map(p=>p===oldTag?newTag:p); saveState(); showToast('Key word renamed','success'); renderKeywordList(getAllKeywordStats()); } function deleteKeywordEverywhere(tag){ const strip=list=>list.forEach(i=>{ if(!i.tags)return; if(i.tags.includes(tag)){i.tags=i.tags.filter(t=>t!==tag);i.updatedAt=Date.now();} }); strip(AppState.standardItems); strip(AppState.ppmItems); if(AppState.pendingKeywords)AppState.pendingKeywords=AppState.pendingKeywords.filter(p=>p!==tag); saveState(); showToast('Key word removed','error'); renderKeywordList(getAllKeywordStats()); } /* ============================================ MODAL (ADD / EDIT) ============================================ */ let modalPhotos=[]; let modalTags=[]; function openModal(mode,id=null){ resetStrayDeleteConfirms(null); // opening Edit/Add is "any other selection" — clear any stray red confirm bar left open elsewhere if(mode==='standard'&&!id&&isStandardLimitReached()){ showUpgradePrompt(); return; } AppState.editingMode=mode; AppState.editingItemId=id; const overlay=document.getElementById('modal-overlay-ip'); const modal=document.getElementById('modal-ip'); let item=null; if(id){ if(mode==='standard')item=AppState.standardItems.find(i=>i.id===id); else item=AppState.ppmItems.find(i=>i.id===id); } // Normalize: older saved items may have photos as plain data-URI strings; // newer ones (with captions) are {url, caption} objects. Support both. modalPhotos=item&&item.photos?item.photos.map(p=>typeof p==='string'?{url:p,caption:''}:{url:p.url,caption:p.caption||''}):[]; modalTags=item&&item.tags?[...item.tags]:[]; const isStd=mode==='standard'; const title=id?(isStd?'Edit Standard Item':'Edit PPM Item'):(isStd?'Add Standard Item':'Add PPM Item'); const stdCategories=STD_CATEGORIES; const ppmCategories=PPM_CATEGORIES; const categories=[...(isStd?stdCategories:ppmCategories),...AppState.customCategories]; const presetLocations=PRESET_LOCATIONS; const locationOptions=[...presetLocations,...AppState.customLocations]; if(item&&item.locationInHome&&!locationOptions.includes(item.locationInHome))locationOptions.push(item.locationInHome); modal.innerHTML=`

${!isStd?'':''}${title}

${item&&item.itemNumber?'Item # '+item.itemNumber:'Item # will be assigned on save'}

Up to 13 characters — for categories not in the list above (e.g. "Guns", "Wine").

${isStd?`
`:`
0 / 2000
`}

Be specific so your executor can find it — for locations not in the list above.

${['Excellent','Good','Fair','Poor'].map(c=>``).join('')}
${isStd?`
`:''}
${modalTags.map(t=>`${t}×`).join('')}

Drag & drop or click to upload

Max ${getPhotoLimit()} photos${AppState.isPremium?' — captions enabled':' (Premium: 6 photos + captions)'}

`; // Apply grid style for md+ const style=document.createElement('style'); style.textContent='@media(min-width:768px){.md\\:grid-cols-modal{grid-template-columns:1.2fr 1fr !important}}'; modal.appendChild(style); overlay.classList.add('visible'); // Events document.getElementById('modal-close-ip').addEventListener('click',closeModal); document.getElementById('m-cancel-ip').addEventListener('click',closeModal); document.getElementById('m-save-ip').addEventListener('click',saveItem); // Live-updating Sub Total (Qty × Price) and Est. Sub Total (Qty × Est. Value) if(isStd){ const qtyEl=document.getElementById('m-qty-ip'); const priceEl=document.getElementById('m-price-ip'); const valueEl=document.getElementById('m-value-ip'); const subtotalEl=document.getElementById('m-subtotal-ip'); const estSubtotalEl=document.getElementById('m-est-subtotal-ip'); const recalcSubtotal=()=>{ const qty=Math.max(1,parseInt(qtyEl.value,10)||1); const price=parseFloat(priceEl.value)||0; const value=parseFloat(valueEl.value)||0; subtotalEl.value=formatCurrency(qty*price); estSubtotalEl.value=formatCurrency(qty*value); }; qtyEl.addEventListener('input',recalcSubtotal); priceEl.addEventListener('input',recalcSubtotal); valueEl.addEventListener('input',recalcSubtotal); } // Condition buttons modal.querySelectorAll('#m-cond-ip button').forEach(b=>b.addEventListener('click',()=>{ modal.querySelectorAll('#m-cond-ip button').forEach(x=>x.classList.remove('active')); b.classList.add('active'); })); // Custom category const catSelect=document.getElementById('m-cat-ip'); const catAddRow=document.getElementById('m-cat-add-row-ip'); document.getElementById('m-cat-add-toggle-ip').addEventListener('click',()=>{ catAddRow.style.display=catAddRow.style.display==='none'?'flex':'none'; if(catAddRow.style.display==='flex')document.getElementById('m-cat-new-input-ip').focus(); }); document.getElementById('m-cat-add-cancel-ip').addEventListener('click',()=>{ catAddRow.style.display='none'; document.getElementById('m-cat-new-input-ip').value=''; document.getElementById('m-cat-new-err-ip').style.display='none'; }); const confirmNewCategory=()=>{ const input=document.getElementById('m-cat-new-input-ip'); const errEl=document.getElementById('m-cat-new-err-ip'); const val=input.value.trim(); const allExisting=[...catSelect.options].map(o=>o.value.toLowerCase()); if(!val){errEl.textContent='Enter a category name.';errEl.style.display='block';return} if(val.length>13){errEl.textContent='13 characters max.';errEl.style.display='block';return} if(allExisting.includes(val.toLowerCase())){errEl.textContent='That category already exists.';errEl.style.display='block';return} AppState.customCategories.push(val); saveState(); const opt=document.createElement('option'); opt.textContent=val; opt.selected=true; catSelect.appendChild(opt); catAddRow.style.display='none'; input.value=''; errEl.style.display='none'; showToast(`Category "${val}" added`,'success'); }; document.getElementById('m-cat-add-confirm-ip').addEventListener('click',confirmNewCategory); document.getElementById('m-cat-new-input-ip').addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();confirmNewCategory()} }); // Custom location const locSelect=document.getElementById('m-loc-ip'); const locAddRow=document.getElementById('m-loc-add-row-ip'); document.getElementById('m-loc-add-toggle-ip').addEventListener('click',()=>{ locAddRow.style.display=locAddRow.style.display==='none'?'flex':'none'; if(locAddRow.style.display==='flex')document.getElementById('m-loc-new-input-ip').focus(); }); document.getElementById('m-loc-add-cancel-ip').addEventListener('click',()=>{ locAddRow.style.display='none'; document.getElementById('m-loc-new-input-ip').value=''; document.getElementById('m-loc-new-err-ip').style.display='none'; }); const confirmNewLocation=()=>{ const input=document.getElementById('m-loc-new-input-ip'); const errEl=document.getElementById('m-loc-new-err-ip'); const val=input.value.trim(); const allExisting=[...locSelect.options].map(o=>o.value.toLowerCase()).filter(v=>v); if(!val){errEl.textContent='Enter a location.';errEl.style.display='block';return} if(allExisting.includes(val.toLowerCase())){errEl.textContent='That location already exists.';errEl.style.display='block';return} AppState.customLocations.push(val); saveState(); const opt=document.createElement('option'); opt.textContent=val; opt.selected=true; locSelect.appendChild(opt); locAddRow.style.display='none'; input.value=''; errEl.style.display='none'; showToast(`Location "${val}" added`,'success'); }; document.getElementById('m-loc-add-confirm-ip').addEventListener('click',confirmNewLocation); document.getElementById('m-loc-new-input-ip').addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();confirmNewLocation()} }); // Tags document.getElementById('m-tag-input-ip').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); const v=e.target.value.trim(); if(v&&!modalTags.includes(v)){ modalTags.push(v); renderModalTags(); } e.target.value=''; } }); // Story char counter const storyEl=document.getElementById('m-story-ip'); if(storyEl){ const updateCount=()=>{document.getElementById('m-story-count-ip').textContent=storyEl.value.length+' / 2000'}; storyEl.addEventListener('input',updateCount); updateCount(); } // Photo upload const dropzone=document.getElementById('m-dropzone-ip'); const fileInput=document.getElementById('m-file-input-ip'); dropzone.addEventListener('click',()=>fileInput.click()); dropzone.addEventListener('dragover',e=>{e.preventDefault();dropzone.classList.add('dragover')}); dropzone.addEventListener('dragleave',()=>dropzone.classList.remove('dragover')); dropzone.addEventListener('drop',e=>{e.preventDefault();dropzone.classList.remove('dragover');handleFiles(e.dataTransfer.files)}); fileInput.addEventListener('change',e=>handleFiles(e.target.files)); // Google search for this item (uses name + brand/model when available for a better query) document.getElementById('m-name-search-ip').addEventListener('click',()=>{ const nameVal=document.getElementById('m-name-ip').value.trim(); if(!nameVal){ showToast('Enter an item name first','error'); document.getElementById('m-name-ip').focus(); return; } const brandEl=document.getElementById('m-brand-ip'); const modelEl=document.getElementById('m-model-ip'); const catEl=document.getElementById('m-cat-ip'); const parts=[nameVal]; if(brandEl&&brandEl.value.trim())parts.push(brandEl.value.trim()); if(modelEl&&modelEl.value.trim())parts.push(modelEl.value.trim()); if(catEl&&catEl.value.trim())parts.push(catEl.value.trim()); const query=encodeURIComponent(parts.join(' ')); window.open(`https://www.google.com/search?q=${query}`,'_blank','noopener'); }); renderModalPhotos(); if(typeof lucide!=="undefined")lucide.createIcons(); } function closeModal(){ document.getElementById('modal-overlay-ip').classList.remove('visible'); AppState.editingItemId=null; AppState.editingMode=null; } function showPlanChoiceModal(pendingView){ const overlay=document.getElementById('modal-overlay-ip'); const modal=document.getElementById('modal-ip'); modal.innerHTML=`
ItemizeItPro

Choose Your Mode

You're signed in! How would you like to use ItemizeItPro?

Standard Mode Free

Up to ${FREE_STANDARD_ITEM_LIMIT} items, 3 photos per item, and core inventory tools — no payment required.

Premium Mode $275 ONE-TIME

Unlimited items, 6 photos per item with captions, and every export unlocked (Insurance Report, PPM Document, CSV, Print Labels).

`; overlay.classList.add('visible'); document.getElementById('plan-choice-close-ip').addEventListener('click',()=>{closeModal();showWelcomeModal();}); document.getElementById('plan-choice-standard-ip').addEventListener('click',()=>{closeModal();showWelcomeModal();}); document.getElementById('plan-choice-premium-ip').addEventListener('click',()=>{ // Remember where the user was headed so we can resume once Premium is approved if(pendingView)localStorage.setItem('ruelleservices_pending_upgrade',pendingView); closeModal(); startPremiumCheckout(); }); if(typeof lucide!=="undefined")lucide.createIcons(); } function showWelcomeModal(){ const overlay=document.getElementById('modal-overlay-ip'); const modal=document.getElementById('modal-ip'); modal.innerHTML=`

WELCOME to 21st Century state of the art, ItemizeItPro Inventory System!

You're signed in and ready to go. Let's get your inventory organized.

`; overlay.classList.add('visible'); document.getElementById('welcome-modal-close-ip').addEventListener('click',closeModal); if(typeof lucide!=="undefined")lucide.createIcons(); } function renderModalTags(){ const d=document.getElementById('m-tags-display-ip'); if(!d)return; d.innerHTML=modalTags.map(t=>`${t}×`).join(''); d.querySelectorAll('.remove-tag').forEach(r=>r.addEventListener('click',()=>{ modalTags=modalTags.filter(x=>x!==r.dataset.tag); renderModalTags(); })); } function handleFiles(files){ const limit=getPhotoLimit(); Array.from(files).forEach(f=>{ if(modalPhotos.length>=limit){ if(!AppState.isPremium)showUpgradePrompt(); return; } const reader=new FileReader(); reader.onload=e=>{ modalPhotos.push({url:e.target.result,caption:''}); renderModalPhotos(); }; reader.readAsDataURL(f); }); } function renderModalPhotos(){ const grid=document.getElementById('m-photos-grid-ip'); if(!grid)return; const showCaptions=AppState.isPremium; grid.innerHTML=modalPhotos.map((p,i)=>`
Photo ${i+1}
${showCaptions?``:''}
`).join(''); grid.querySelectorAll('.photo-remove-btn').forEach(b=>b.addEventListener('click',()=>{ modalPhotos.splice(parseInt(b.dataset.pi),1); renderModalPhotos(); })); grid.querySelectorAll('.photo-caption-input').forEach(inp=>inp.addEventListener('input',e=>{ modalPhotos[parseInt(inp.dataset.pi)].caption=e.target.value; })); } function saveItem(){ const mode=AppState.editingMode; const isStd=mode==='standard'; const nameEl=document.getElementById('m-name-ip'); const name=nameEl.value.trim(); const errEl=document.getElementById('m-name-err-ip'); if(!name){ nameEl.classList.add('error'); errEl.textContent='Item name is required'; errEl.style.display='block'; return; } nameEl.classList.remove('error'); errEl.style.display='none'; const condEl=document.querySelector('#m-cond-ip button.active'); const condition=condEl?condEl.dataset.cond:'Good'; const nowTs=Date.now(); if(isStd){ const item={ id:AppState.editingItemId||uuid(), itemNumber:AppState.editingItemId?(AppState.standardItems.find(i=>i.id===AppState.editingItemId)||{}).itemNumber:formatItemNumber('S',++AppState.stdItemCounter), name, category:document.getElementById('m-cat-ip').value, description:document.getElementById('m-desc-ip').value, quantity:Math.max(1,parseInt(document.getElementById('m-qty-ip').value,10)||1), purchasePrice:parseFloat(document.getElementById('m-price-ip').value)||0, currentValue:parseFloat(document.getElementById('m-value-ip').value)||0, purchaseDate:document.getElementById('m-pdate-ip').value, serialNumber:document.getElementById('m-serial-ip').value, brand:document.getElementById('m-brand-ip').value, model:document.getElementById('m-model-ip').value, condition, locationInHome:document.getElementById('m-loc-ip').value, insurancePolicyNum:document.getElementById('m-inspol-ip').value, insuranceValue:parseFloat(document.getElementById('m-insval-ip').value)||0, warrantyExpiration:document.getElementById('m-warranty-ip').value, tags:modalTags, photos:modalPhotos, createdAt:AppState.editingItemId?(AppState.standardItems.find(i=>i.id===AppState.editingItemId)||{}).createdAt||nowTs:nowTs, updatedAt:nowTs }; if(AppState.editingItemId){ const idx=AppState.standardItems.findIndex(i=>i.id===AppState.editingItemId); if(idx>-1)AppState.standardItems[idx]=item; }else{ AppState.standardItems.push(item); } }else{ const item={ id:AppState.editingItemId||uuid(), itemNumber:AppState.editingItemId?(AppState.ppmItems.find(i=>i.id===AppState.editingItemId)||{}).itemNumber:formatItemNumber('P',++AppState.ppmItemCounter), name, category:document.getElementById('m-cat-ip').value, description:document.getElementById('m-desc-ip').value, bequeathTo:[{name:document.getElementById('m-bequeath-ip').value,isPrimary:true}], familyStory:document.getElementById('m-story-ip').value, executorNotes:document.getElementById('m-executor-ip').value, locationInHome:document.getElementById('m-loc-ip').value, condition, tags:modalTags, photos:modalPhotos, createdAt:AppState.editingItemId?(AppState.ppmItems.find(i=>i.id===AppState.editingItemId)||{}).createdAt||nowTs:nowTs, updatedAt:nowTs }; if(AppState.editingItemId){ const idx=AppState.ppmItems.findIndex(i=>i.id===AppState.editingItemId); if(idx>-1)AppState.ppmItems[idx]=item; }else{ AppState.ppmItems.push(item); } } saveState(); closeModal(); showToast(AppState.editingItemId?'Item updated successfully':'Item added successfully','success'); renderView(); updateModeIndicator(); } /* ============================================ DELETE / DUPLICATE ============================================ */ // Fixes the "red delete-confirm bar never disappears" bug: showDeleteConfirm() // swaps ONE action row's innerHTML in place for the red Confirm/Cancel bar. // That's a targeted DOM edit, not a re-render, so nothing else on screen // knows it happened. Deleting, duplicating, or changing search/filter/sort // all happen to force a full grid re-render, which wipes the red bar as a // side effect — but opening the Edit/Add modal (or confirming a delete on a // DIFFERENT item) does not touch the grid at all, so a red bar left open // elsewhere stays on screen indefinitely. This walks the DOM for any other // open confirm bar and programmatically clicks its own Cancel button, reusing // the existing cancel-del handler (which calls renderView()) instead of // duplicating that logic. function resetStrayDeleteConfirms(keepId){ document.querySelectorAll('[data-action="cancel-del"]').forEach(cancelBtn=>{ if(!document.body.contains(cancelBtn))return; // an earlier iteration's renderView() may have already removed it if(keepId&&cancelBtn.dataset.id===keepId)return; // leave the bar the user is actively engaging with alone cancelBtn.click(); }); } function showDeleteConfirm(mode,id,btn){ resetStrayDeleteConfirms(id); // resetStrayDeleteConfirms() may have just triggered a renderView() (to // clear a stray bar elsewhere), which rebuilds the grid/table from scratch // and detaches the original `btn` node passed in. Fall back to a fresh // lookup by id in that case instead of using the now-stale reference. if(btn&&!document.body.contains(btn))btn=null; // Prefer scoping to the exact clicked button's actions container. Several // views (grid cards, the PPM Admin Review detail panel, and the // cross-user Admin table) all render an actions bar using the same // constructed id ("std-actions-" / "ppm-actions-") for the same // item. document.getElementById() only ever returns one match, so if two // of those containers exist in the DOM at once it can silently target the // wrong element and leave the clicked Delete button on screen. closest() // guarantees we always operate on the element the user actually clicked. const actionsEl=btn?btn.closest('[id$="-actions-'+id+'"]'):document.getElementById((mode==='standard'?'std':'ppm')+'-actions-'+id); if(!actionsEl){ deleteItem(mode,id); return; } actionsEl.innerHTML=`
Delete? Cannot undo.
`; actionsEl.querySelector('[data-action="confirm-del"]').addEventListener('click',()=>deleteItem(mode,id)); actionsEl.querySelector('[data-action="cancel-del"]').addEventListener('click',()=>renderView()); } function deleteItem(mode,id){ if(mode==='standard'){ AppState.standardItems=AppState.standardItems.filter(i=>i.id!==id); }else{ AppState.ppmItems=AppState.ppmItems.filter(i=>i.id!==id); if(AppState.activeAdminItem===id)AppState.activeAdminItem=null; } saveState(); showToast('Item deleted','error'); renderView(); updateModeIndicator(); } function duplicateItem(mode,id){ if(mode==='standard'&&isStandardLimitReached()){ showUpgradePrompt(); return; } const src=mode==='standard'?AppState.standardItems.find(i=>i.id===id):AppState.ppmItems.find(i=>i.id===id); if(!src)return; const copy={...src,id:uuid(),itemNumber:formatItemNumber(mode==='standard'?'S':'P',mode==='standard'?++AppState.stdItemCounter:++AppState.ppmItemCounter),name:src.name+' (Copy)',createdAt:Date.now(),updatedAt:Date.now(),tags:[...(src.tags||[])],photos:(src.photos||[]).map(p=>typeof p==='string'?{url:p,caption:''}:{...p})}; if(src.bequeathTo)copy.bequeathTo=src.bequeathTo.map(b=>({...b})); if(mode==='standard')AppState.standardItems.push(copy); else AppState.ppmItems.push(copy); saveState(); showToast('Item duplicated','success'); renderView(); updateModeIndicator(); } /* ============================================ INIT ============================================ */ // Register the service worker (enables "Install App" / "Add to Home Screen" // on Chrome/Android and lets the app shell load even on a weak connection). if('serviceWorker' in navigator){ window.addEventListener('load',()=>{ navigator.serviceWorker.register('/sw.js').catch(e=>console.warn('SW registration failed',e)); }); } // Sidebar nav document.querySelectorAll('.nav-item[data-view]').forEach(n=>{ n.addEventListener('click',()=>{ if(n.dataset.view==='standard'&&!AppState.userEmail){ showEmailGateModal('standard'); return; } if(n.dataset.view==='admin'&&!isAdminUser()){ showAdminGateModal(()=>navigate('admin')); return; } if(n.dataset.view==='premium-full'){ showUpgradePrompt(); return; } navigate(n.dataset.view); }); }); // Mobile sidebar toggle document.getElementById('mobile-menu-btn-ip').addEventListener('click',()=>{ document.getElementById('sidebar-ip').classList.toggle('expanded'); }); // Keyboard shortcut document.addEventListener('keydown',e=>{ if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return; if(e.key==='n'||e.key==='N'){ e.preventDefault(); if(AppState.currentView==='ppm')openModal('ppm'); else openModal('standard'); } if(e.key==='Escape'){ closeModal(); document.getElementById('sidebar-ip').classList.remove('expanded'); } }); // Close modal on overlay click document.getElementById('modal-overlay-ip').addEventListener('click',e=>{ if(e.target===e.currentTarget)closeModal(); }); // Header clock — day, date, and time in US Central time (America/Chicago // auto-switches between CST and CDT, so the label stays correct year-round). function updateHeaderClock(){ const timeEl=document.getElementById('header-clock-time-ip'); const dateEl=document.getElementById('header-clock-date-ip'); if(!timeEl||!dateEl)return; const now=new Date(); const timeFmt=new Intl.DateTimeFormat('en-US',{timeZone:'America/Chicago',hour:'numeric',minute:'2-digit',hour12:true,timeZoneName:'short'}); const dateFmt=new Intl.DateTimeFormat('en-US',{timeZone:'America/Chicago',weekday:'long',month:'short',day:'numeric',year:'numeric'}); timeEl.textContent=timeFmt.format(now); dateEl.textContent=dateFmt.format(now); } updateHeaderClock(); setInterval(updateHeaderClock,15000); // Loads item data/assets and lands the visitor on their requested view. // Deferred until AFTER the email gate is resolved (see below) on a // first-time guest visit, so nothing loads behind the gate. async function startApp(){ await initState(); if(AppState.isPremium){ const badge=document.getElementById('profile-premium-badge-ip'); if(badge)badge.style.display='flex'; } checkPremiumReturn(); const launchParams=new URLSearchParams(window.location.search); const requestedView=launchParams.get('view'); const validViews=['dashboard','reports','standard','ppm','categories','documents','keywords','settings','admin','help']; navigate(validViews.includes(requestedView)?requestedView:'dashboard'); } // Ask the backend if this browser already has a valid session BEFORE loading // item data, so a signed-in visitor's items come from their account (not a // flash of guest/localStorage data). First-time guests see the email gate // immediately, with asset loading (startApp) held until they either sign // in or choose "Maybe Later" — nothing loads behind the gate. (async()=>{ await checkAuthSession(); if(!AppState.userEmail&&!localStorage.getItem('ruelleservices_email_gate_seen')){ localStorage.setItem('ruelleservices_email_gate_seen','1'); showEmailGateModal(null,startApp); }else{ await startApp(); } })();

ItemizeItPro Copyright Ruelle.Services