Offline — orders save locally{{ offlineQueueCount ? ' · ' + offlineQueueCount + ' queued' : '' }}, syncing when you reconnect.
{{ offlineQueueCount }} offline order{{ offlineQueueCount > 1 ? 's' : '' }} pending sync

Order saved offline

It’ll sync automatically when you’re back online.

{{ offlineQueueCount }} order{{ offlineQueueCount > 1 ? 's' : '' }} queued locally

Offline Orders ({{ offlineQueueCount }})

No offline orders queued.

{{ o.itemCount }} item{{ o.itemCount > 1 ? 's' : '' }} · ${{ (o.total || 0).toFixed(2) }}

{{ o.payment?.method || 'unpaid' }} · {{ o.locationName }}

queued
Version {{ latestAvailableVersion }} is available
🌴

La Palma Express

Ferry Terminal

🌴

La Palma Express

Ferry Terminal

v{{ APP_VERSION }}

{{ loginError }}

{{ loginError }}

🔍
🔍

No products found

Current Order

#{{ currentOrderNumber }}

{{ item.name }}

${{ item.price.toFixed(2) }} each

{{ item.quantity }}
${{ (item.price * item.quantity).toFixed(2) }}
Subtotal${{ cartSubtotal.toFixed(2) }}
Resident (10%)-${{ residentDiscount.toFixed(2) }}
Discount-${{ manualDiscountAmount.toFixed(2) }}
Tax (11.5%)${{ cartTax.toFixed(2) }}
Total${{ cartTotal.toFixed(2) }}

Search Results

{{ selectedCategoryData.name }}

Favorites

🔍

No products found

Current Order

#{{ currentOrderNumber }}
🔍
👤

{{ getCustomerDisplayName(selectedCustomer) }}

🏠 Resident (10% off)

🛒

Cart is empty

{{ item.name }}

${{ item.price.toFixed(2) }} each

{{ item.quantity }}
${{ (item.price * item.quantity).toFixed(2) }}
Subtotal ${{ cartSubtotal.toFixed(2) }}
Resident Discount (10%) -${{ residentDiscount.toFixed(2) }}
Discount -${{ manualDiscountAmount.toFixed(2) }}
Tax (11.5%) ${{ cartTax.toFixed(2) }}
Total ${{ cartTotal.toFixed(2) }}

Orders

#{{ order.order_number }}

👤 {{ order.customer_name }}

{{ getOrderAge(order.created_at) }}

{{ order.order_type?.replace('_', ' ') }}

{{ order.status }} {{ order.payment_status }}
{{ order.item_count }} items ${{ parseFloat(order.total).toFixed(2) }}
Order Customer Type Items Total Status Payment Actions

#{{ order.order_number }}

{{ getOrderAge(order.created_at) }}

{{ order.customer_name }} {{ order.order_type?.replace('_', ' ') }} {{ order.item_count }} items ${{ parseFloat(order.total).toFixed(2) }} {{ order.status }} {{ order.payment_status }}
📋

No orders found

Products

🔍
{{ selectedProducts.length }} selected
Product Category Price Status Actions

{{ product.name }}

{{ product.name_es }}

{{ parseFloat(product.inventory_stock || 0).toFixed(1) }} {{ product.inventory_unit }}
{{ product.category_name }} ${{ parseFloat(product.price).toFixed(2) }} {{ product.is_active ? 'Active' : 'Inactive' }}

Categories

Drag to reorder. Order affects display on POS.

{{ category.name }}

{{ category.name_es || '—' }}

{{ category.is_active ? 'Active' : 'Inactive' }}

Kitchen

Select Recipe

No production recipes configured yet. Tap "Create Recipe" to add one.

{{ kitchenRecipeEditId ? 'Edit Recipe' : 'Create Recipe' }}

A batch increases this product's stock at the location where it's produced.

{{ ing._name }}

{{ ing._unit }}

{{ kitchenRecipeDetail.name }}

Makes {{ kitchenRecipeDetail.output_quantity }} per batch

Output product: {{ kitchenRecipeDetail.output_product.name }} ${{ parseFloat(kitchenRecipeDetail.output_product.price).toFixed(2) }}
No output product set — edit the recipe and pick one before producing.
Pick a location to see on-hand stock and produce a batch.

Ingredients needed at {{ selectedLocation.name }}

{{ ing.ingredient_name }} {{ (ing.quantity_required * kitchenBatchMultiplier).toFixed(2) }} {{ ing.unit }} (have {{ parseFloat(ing.quantity_on_hand).toFixed(2) }})
{{ kitchenBatchMultiplier }}

Record Waste

Closing Count

{{ item.name }}

Expected: {{ parseFloat(item.expected_quantity).toFixed(1) }} {{ item.unit }}

No prepared items to count.

Receive Stock

Activity Log

{{ entry.entry_type === 'batch' ? '🧑‍🍳' : '🗑️' }} {{ entry.entry_type === 'batch' ? 'Batch Produced' : 'Waste Recorded' }} {{ entry.entry_type }}

{{ entry.item_name }}

By {{ entry.performed_by }}

{{ new Date(entry.entry_date).toLocaleDateString() }}

{{ new Date(entry.entry_date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }}

Exp: {{ new Date(entry.expires_at).toLocaleDateString() }}

No activity found{{ kitchenLogFilter !== 'all' ? ' for this filter' : '' }}.

TIME'S UP!

{{ bakingSession.steps[bakingSession.current_step - 1]?.title }}

Baking

{{ recipe.name }}

{{ recipe.output_quantity }} {{ recipe.output_unit }} of {{ recipe.output_item_name }}

Stock: {{ parseFloat(recipe.current_stock).toFixed(1) }}
{{ recipe.step_count }} steps {{ recipe.total_time }} min No steps yet

No production recipes found. Create recipes in the Kitchen section first.

{{ bakingSelectedRecipe.name }} — Steps

Total time: {{ bakingSelectedRecipe.total_time || 0 }} min · {{ (bakingSelectedRecipe.steps || []).length }} steps

{{ step.step_number }}

{{ step.title }}

{{ step.description }}

{{ step.step_type }} {{ step.duration_minutes }}m

No steps yet. Add steps below.

Add Step

{{ bakingSession.recipe_name }}

Batch: {{ bakingSession.batch_multiplier }}x

Step {{ bakingSession.current_step }} of {{ bakingSession.total_steps }} {{ bakingSession.steps[bakingSession.current_step - 1].step_type }}

{{ bakingSession.steps[bakingSession.current_step - 1].title }}

{{ bakingSession.steps[bakingSession.current_step - 1].description }}

{{ formatBakingTimer(bakingTimerSeconds) }}

{{ bakingSession.steps[bakingSession.current_step - 1].duration_minutes }} min total

Ingredients ({{ bakingSession.batch_multiplier }}x batch)
{{ ing.item_name }} {{ (ing.quantity_required * bakingSession.batch_multiplier).toFixed(2) }} {{ ing.unit }}

{{ (user.role === 'admin' || user.role === 'manager') ? 'My Shift Today' : "Today's Baking" }}

{{ bakingMyShift.status }}
{{ formatTime12h(bakingMyShift.start_time) }} • Shift: {{ Math.floor(bakingMyShift.total_budget_minutes / 60) }}h {{ bakingMyShift.total_budget_minutes % 60 }}m • Scheduled: {{ Math.floor(bakingMyShift.total_scheduled_minutes / 60) }}h {{ bakingMyShift.total_scheduled_minutes % 60 }}m
Start: {{ formatTime12h(bakingMyShift.start_time) }} Today's Baking List
{{ bakingMyShift.items_completed }} of {{ bakingMyShift.items_total }} items done
{{ idx + 1 }}. {{ item.recipe_name }} ({{ item.batch_multiplier }}x) {{ item.actual_minutes }}m {{ item.estimated_minutes }}m est Done Baking Pending
Recipe User Started Status Multiplier
{{ s.recipe_name }} {{ s.user_name }} {{ new Date(s.started_at).toLocaleString() }} {{ s.status }} {{ s.batch_multiplier }}x

No baking sessions yet.

Inventory

Products and ingredients across all locations

Showing {{ visibleStockRows.length }} of {{ invView === 'stock' ? stockMatrix.length : ingredientMatrix.length }}
Product {{ sortIndicator('product_name') }} Category {{ sortIndicator('category_name') }} {{ loc.name }} {{ sortIndicator('loc_' + loc.id) }} Total {{ sortIndicator('total') }} Actions

{{ row.product_name }} inactive

${{ parseFloat(row.price).toFixed(2) }}

{{ row.category_name || '—' }} {{ row.quantities[loc.id]?.quantity ?? 0 }} {{ row.total }}
No products yet. Add some via the Products tab. No products match the current filters.
Ingredient {{ sortIndicator('name') }} Unit {{ sortIndicator('unit') }} Supplier {{ sortIndicator('supplier') }} {{ loc.name }} {{ sortIndicator('loc_' + loc.id) }} Total {{ sortIndicator('total') }} Actions

{{ row.name }}

PAR {{ row.par_min }}–{{ row.par_target }}

{{ row.unit }} {{ row.supplier || '—' }} {{ formatQty(row.quantities[loc.id]?.quantity ?? 0, row.unit) }} {{ formatQty(row.total, row.unit) }}
No ingredients yet. Click "+ Add Ingredient" above. No ingredients match the current filters.
When Product From To Qty By
{{ new Date(t.transferred_at).toLocaleString() }} {{ t.product_name }} {{ t.from_location_name }} {{ t.to_location_name }} {{ t.quantity }} {{ [t.user_first_name, t.user_last_name].filter(Boolean).join(' ') || '—' }}
No transfers logged yet.
When Kind Item Location Action Before → After Δ By Notes
{{ new Date(a.created_at).toLocaleString() }} {{ a.entity_type }} {{ a.entity_name || ('#' + a.entity_id) }} {{ a.location_name || '—' }} {{ a.action_type }} {{ parseFloat(a.qty_before).toFixed(2) }} → {{ parseFloat(a.qty_after).toFixed(2) }} {{ parseFloat(a.qty_delta) > 0 ? '+' : '' }}{{ parseFloat(a.qty_delta).toFixed(2) }} {{ a.user_name || '—' }} {{ a.notes || '' }}
No adjustments logged yet.

{{ parReportSummary.critical || 0 }}

Critical

{{ parReportSummary.low || 0 }}

Low

{{ parReportSummary.ok || 0 }}

OK

Item On Hand PAR Min Target Order Qty Status

{{ item.name }}

{{ item.category_tag || '' }} {{ item.supplier ? '/ ' + item.supplier : '' }}

{{ parseFloat(item.quantity_on_hand).toFixed(1) }} {{ item.unit }} {{ parseFloat(item.par_min).toFixed(1) }} {{ parseFloat(item.par_target).toFixed(1) }} {{ item.suggested_order_qty > 0 ? parseFloat(item.suggested_order_qty).toFixed(1) + ' ' + item.unit : '—' }} ({{ item.suggested_purchase_units }} {{ item.purchase_unit }}s) {{ item.status }}

{{ transferMode === 'get' ? 'Get From Main' : 'Return To Main' }}

{{ transferMode === 'get' ? 'Move stock from Main to ' + (selectedLocation?.name || 'here') + '.' : 'Move stock from ' + (selectedLocation?.name || 'here') + ' back to Main.' }}

Loading... No products match "{{ transferSearch }}" Nothing available to transfer.

{{ row.product_name }}

Source has {{ row.source_qty }} · already at dest: {{ row.dest_qty }}

Adjust stock

{{ adjustForm.type === 'product' ? 'Product' : 'Ingredient' }}: {{ adjustForm.name }}

Current: {{ formatQty(adjustCurrentQty, adjustForm.unit) }} · After: {{ formatQty(adjustPreview, adjustForm.unit) }}

{{ confirmConfig.title }}

{{ confirmConfig.message }}

Add a Time Entry

For someone who never punched in. This is recorded in the audit log under your name.

Leave empty only if they are still on the clock right now.

{{ addTimeEntryError }}

{{ scheduleForm.id ? 'Edit Shift' : 'Add a Shift' }}

Rota weeks run Friday to Thursday, the same as a pay period. No more than {{ SCHEDULE_MAX_CONCURRENT }} people on at the same time.

No staff loaded yet.

{{ scheduleDateIssue }}

This shift is on a week already worked. You can correct it here or delete it — but moving it to another day means picking one inside the open rota{{ scheduleEditableRange.min ? ', ' + scheduleEditableRange.label : '' }}.

That is a fourth person at {{ formatTime12h(scheduleConflict.at) }}

Already on: {{ scheduleConflict.names.join(', ') }}. Only {{ SCHEDULE_MAX_CONCURRENT }} can be on at once, so saving this will be rejected — move a time or drop someone first.

{{ schedulePersonConflict.name }} is already on at that time

Their existing shift that day runs {{ formatTime12h(schedulePersonConflict.start) }} – {{ formatTime12h(schedulePersonConflict.end) }} at {{ schedulePersonConflict.location }}. Nobody can be booked twice over the same hours, so saving this will be rejected — change the times, or edit that shift instead of adding a second one.

{{ scheduleError }}

{{ ingredientForm.id ? 'Edit' : 'Add' }} Ingredient

Customers

Customer Contact Resident Orders Visits Total Spent Actions

{{ getCustomerDisplayName(customer) }}

{{ customer.first_name }} {{ customer.last_name }}

{{ customer.email || '—' }}

{{ formatDisplayPhone(customer.phone) || '—' }}

🏠 0
{{ customer.loyalty_visits }} visits {{ customer.loyalty_rewards_lifetime }} earned

Free item earned!

{{ customer.loyalty_visits_month || 0 }} this month

${{ parseFloat(customer.total_spent || 0).toFixed(2) }}

Dashboard

Today's Revenue

${{ dashboardData?.today?.total_revenue?.toFixed(2) || '0.00' }}

Orders Today

{{ dashboardData?.today?.total_orders || 0 }}

Active Orders

{{ dashboardData?.active_orders || 0 }}

Low Stock Items

{{ dashboardData?.low_stock_count || 0 }}

Top Products Today

{{ index + 1 }} {{ product.product_name }}

{{ product.quantity }} sold

${{ parseFloat(product.revenue).toFixed(2) }}

AI Insights

AI-powered business intelligence

⚠️

API Key Required

Add your Anthropic API key in Settings to enable AI insights.

Latest Insight - {{ aiLatestInsight.insight_date }}

{{ aiLatestInsight.insight_type }} analysis | Confidence: {{ Math.round((aiLatestInsight.confidence_score || 0) * 100) }}%

{{ aiLatestInsight.tokens_used?.toLocaleString() }} tokens
${{ Number(aiLatestInsight.cost_usd).toFixed(4) }}
~${{ (aiLatestInsight.tokens_used * 0.000006).toFixed(4) }}

{{ aiLatestInsight.full_analysis?.summary || aiLatestInsight.summary }}

{{ aiLatestInsight.model_used }} In: {{ aiLatestInsight.input_tokens?.toLocaleString() }} Out: {{ aiLatestInsight.output_tokens?.toLocaleString() }} {{ (aiLatestInsight.generation_time_ms / 1000).toFixed(1) }}s

📊 Sales Analysis

{{ aiLatestInsight.full_analysis?.sales_analysis?.overview }}

Trends

  • {{ trend }}

Peak Hours

{{ h }}

🌤️ Weather Impact

{{ aiLatestInsight.full_analysis?.weather_correlation?.impact }}

  • {{ p }}

🍽️ Product Recommendations

Feature These

{{ p }}

Consider Promoting

{{ p }}

Watch List

{{ p }}

✅ Action Items

{{ item.priority }}

{{ item.action }}

{{ item.reason }}

No action items

⚡ Anomalies Detected

  • ⚠️ {{ a }}

🔮 Forecast & Preparations

{{ aiLatestInsight.full_analysis.forecasting.outlook }}

  • {{ p }}
🤖

No Insights Yet

Generate your first AI insight to get started with business intelligence.

📋 Business Notes

Notes are included as context for AI analysis (events, promotions, weather notes, etc.)

No business notes yet. Add notes to give the AI context about your business.

{{ noteTypes.find(t => t.value === note.note_type)?.icon || '📝' }}

{{ note.title }}

{{ note.note_type }}

{{ note.content }}

{{ note.note_date }} | by {{ note.author_first_name || 'System' }}

Loyalty Program

Manage rewards rules and view reports

Enrolled

{{ loyaltyReport.enrolled_customers }}

Active This Month

{{ loyaltyReport.active_this_month }}

Rewards Issued

{{ loyaltyReport.rewards_issued_this_month }}

Redeemed ($)

${{ (loyaltyReport.redemption_value_this_month || 0).toFixed(2) }}

Top Visitors

{{ v.first_name }} {{ v.last_name }} ({{ v.total_visits }} visits)
Rule Type Reward Active Actions

{{ rule.name }}

{{ rule.reward_description }}

Every {{ rule.trigger_count }} visits Surprise ({{ (rule.surprise_probability * 100).toFixed(0) }}%) Category ({{ rule.trigger_count }}) {{ rule.rule_type }} Free Item ${{ parseFloat(rule.reward_value).toFixed(2) }} off {{ rule.reward_value }}% off {{ rule.is_active ? 'Yes' : 'No' }}

No loyalty rules configured.

Settings

Payroll Deductions

Everything is off until you switch it on, and nothing is withheld from anyone while it is off. These rates are yours to confirm with the accountant — they are starting points, not advice. Employer rows are recorded for cost reporting and never come off anyone's pay.

Nothing loaded yet — tap Reload.
{{ d.label }} employer
{{ d.notes }}
{{ d.calc_method === 'fixed' ? '$' : '%' }}

Per-employee — set each person's rate on their staff record from their Form 499 R-4.1, not here. Switch this on to include the line on stubs.

Changing a rate only affects payroll generated after the change. Stubs already issued keep the rate that was in force when they were run.

Business Information

POS Settings

Sales Tax (State + Municipality)

Each order line is taxed by its category's bracket. State rate varies; municipality rate applies to everything.

Categories auto-suggest a bracket from their name when you create them (e.g., "Beer & Seltzers" → Alcohol). You can override per category.

Order Time Alerts

Order age color: green → yellow (warning) → red (urgent)

Stripe

Accept card payments via Stripe Checkout

Test Mode ACTIVE

{{ stripeConfig.test_secret_key_masked || 'Not set' }}

{{ stripeConfig.test_webhook_secret_masked || 'Not set' }}

Live Mode ACTIVE

{{ stripeConfig.live_secret_key_masked || 'Not set' }}

{{ stripeConfig.live_webhook_secret_masked || 'Not set' }}

Leave blank to auto-detect. Required if the app is behind a proxy.

Webhook URL: {{ stripeConfig.webhook_url || 'Unavailable until app has a public URL' }}

Register this URL in your Stripe dashboard → Webhooks. Events needed: checkout.session.completed

Payment Methods

Cash is always available. Stripe uses a hosted Checkout page and records completed payments back into orders and reports.

🤖 AI Insights

Required for AI insights. Get one at console.anthropic.com

Optional. Enables weather correlation analysis.

{{ step.status === 'pass' ? 'Pass' : step.status === 'skip' ? 'Skip' : 'FAIL' }} {{ key.replace(/_/g, ' ') }} {{ step.detail || step.version || '' }}{{ step.time_s ? ' (' + step.time_s + 's)' : '' }}

Locations

No locations yet.
{{ loc.name }} Inactive Kitchen Selected
/menu?loc={{ loc.slug }}{{ loc.address ? ' · ' + loc.address : '' }}

{{ locationForm.id ? 'Edit Location' : 'Add Location' }}

/menu?loc=slug

Account Settings

Change Password

Change PIN

Stock at {{ selectedLocation?.name || 'this location' }}

What you have on hand right now.

Pick a location first.
Product On Hand

{{ row.product_name }}

{{ row.category_name || '' }}

{{ row.quantity }}
Loading… No products match "{{ myStockSearch }}" Nothing in stock at this location yet.
{{ (user?.nickname || user?.first_name || '?').charAt(0).toUpperCase() }}

{{ user?.nickname || user?.first_name }} {{ user?.last_name }}

{{ user?.email }}

{{ user?.role }}

Currently working at

{{ selectedLocation?.name || 'No location selected' }}

POS Theme

Pick a vibe for your workspace

Dark cards

Flip page-title pills + section labels to dark surface with light text

Your PIN

{{ myPin.pin }}

{{ myPin.reason }}

Your PIN is used to quickly sign in at the terminal.

Time Clock

{{ timeClockElapsed || '00:00:00' }}

Clocked in since {{ new Date(timeClockStatus.entry?.clock_in_at).toLocaleTimeString() }}

--:--:--

Not clocked in

This week total {{ Number(timeClockSummary.total_hours || 0).toFixed(1) }}h
Date Clock In Clock Out Hours Status
{{ new Date(entry.clock_in_at).toLocaleDateString() }} {{ new Date(entry.clock_in_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }} {{ entry.clock_out_at ? new Date(entry.clock_out_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : '—' }} {{ entry.clock_out_at ? (((new Date(entry.clock_out_at) - new Date(entry.clock_in_at)) / 3600000) - (entry.break_minutes || 0) / 60).toFixed(1) + 'h' : '—' }} {{ entry.status === 'closed' ? 'Ready to Review' : entry.status }}
No entries this week
${{ myPaySummary.last_payment_amount != null ? Number(myPaySummary.last_payment_amount).toFixed(2) : '0.00' }}
Last Paid
${{ Number(myPaySummary.since_last_payroll?.estimated || 0).toFixed(2) }}
Since Last Payroll
{{ Number(myPaySummary.since_last_payroll?.hours || 0).toFixed(1) }}h
Unpaid Hours
${{ Number(myPaySummary.pay_rate || 0).toFixed(2) }}/hr
Pay Rate

{{ myPayView === 'mtd' ? 'Month to Date' : 'Year to Date' }}

{{ Number(myPaySummary[myPayView]?.hours || 0).toFixed(1) }}h
Hours Worked
${{ Number(myPaySummary[myPayView]?.paid || 0).toFixed(2) }}
Total Paid
${{ Number(myPaySummary[myPayView]?.estimated || 0).toFixed(2) }}
Total Earned (Est.)

Upcoming Payroll

Period Hours Rate Est. Gross Status
{{ item.start_date }} — {{ item.end_date }} {{ Number(item.hours_worked).toFixed(1) }}h ${{ Number(item.rate).toFixed(2) }}/hr ${{ Number(item.gross_amount).toFixed(2) }} due
Since last payroll {{ Number(myPaySummary.since_last_payroll?.hours || 0).toFixed(1) }}h ${{ Number(myPaySummary.pay_rate || 0).toFixed(2) }}/hr ${{ Number(myPaySummary.since_last_payroll?.estimated || 0).toFixed(2) }} estimate
No hours since last payroll

Payment History

Period Amount Method Reference Date
{{ row.start_date }} — {{ row.end_date }} ${{ Number(row.amount).toFixed(2) }} {{ row.method || '—' }} {{ row.reference || '—' }} {{ row.date ? new Date(row.date).toLocaleDateString() : '—' }}
No payments recorded yet
{{ timeClockFilteredEntries.length }} of {{ timeClockAllEntries.length }} entries
Staff Where Date In Out Hours Status Actions
{{ entry.nickname || entry.first_name }} {{ entry.location_name || '—' }} {{ new Date(entry.clock_in_at).toLocaleDateString() }} {{ new Date(entry.clock_in_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }} {{ entry.clock_out_at ? new Date(entry.clock_out_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : '—' }} {{ entry.clock_out_at ? (((new Date(entry.clock_out_at) - new Date(entry.clock_in_at)) / 3600000) - (entry.break_minutes || 0) / 60).toFixed(1) + 'h' : '—' }} {{ timeEntryStatusLabel(entry) }}
No entries to review

Edit Time Entry

Payroll

Period Employees Total Gross Total Paid Status Actions
{{ period.start_date }} — {{ period.end_date }} {{ period.employee_count || 0 }} ${{ Number(period.total_gross || 0).toFixed(2) }} ${{ Number(period.total_paid || 0).toFixed(2) }} {{ period.status }}
No pay periods yet

{{ payrollSelectedPeriod.start_date }} — {{ payrollSelectedPeriod.end_date }}

Created by {{ payrollSelectedPeriod.created_by_name }}

{{ payrollSelectedPeriod.status }}

{{ payrollWarnings.length }} thing{{ payrollWarnings.length === 1 ? '' : 's' }} to look at

Nothing below was added or deducted automatically. Decide each one, adjust the hours if needed, then re-generate.

  • {{ w.employee }} — {{ w.date }} — {{ w.message }}
Employee Hours Rate Gross Deductions Net Notes Status Actions
{{ item.nickname || item.first_name }} {{ item.last_name || '' }} ${{ Number(item.rate).toFixed(2) }}/hr ${{ Number(item.gross_amount).toFixed(2) }} −${{ Number(item.deductions_total).toFixed(2) }}
{{ di ? ' · ' : '' }}{{ d.label }}
${{ Number(item.net_amount || item.gross_amount).toFixed(2) }} {{ item.notes || '' }} {{ item.status }}
No items. Click Generate to create payroll.

Summary

{{ payrollSelectedPeriod.summary.total_employees }}
Employees
{{ payrollSelectedPeriod.summary.total_hours }}
Total Hours
{{ Number(payrollSelectedPeriod.summary.total_overtime_hours || 0).toFixed(1) }}
Premium Hours
${{ Number(payrollSelectedPeriod.summary.total_gross).toFixed(2) }}
Gross
−${{ Number(payrollSelectedPeriod.summary.total_deductions || 0).toFixed(2) }}
Deductions
${{ Number(payrollSelectedPeriod.summary.total_net ?? payrollSelectedPeriod.summary.total_gross).toFixed(2) }}
Net Pay
Paid ${{ Number(payrollSelectedPeriod.summary.total_paid).toFixed(2) }} · ${{ Number(payrollSelectedPeriod.summary.total_remaining).toFixed(2) }} remaining

{{ payrollSelectedPeriod.status === 'processing' ? 'Employee Signature' : 'Manager Signature' }}

{{ payrollPreflight.summary?.staff_count || 0 }} staff checked

The readiness check did not answer. Nothing is blocked — but look over the hours yourself before generating.

These hours cannot be priced correctly yet. Fix each one, then Re-check.

  • {{ c.user_name }} — {{ preflightReason(c.kind) }} — {{ c.detail }}

Not blocking. Nothing below was changed automatically.

  • {{ c.user_name }} — {{ preflightReason(c.kind) }} — {{ c.detail }}
Verified & Locked

Manager: {{ payrollSelectedPeriod.verified_by_name }}

Finalized: {{ new Date(payrollSelectedPeriod.finalized_at).toLocaleString() }}

Employee
Manager

Activity Log

{{ new Date(entry.created_at).toLocaleString() }} {{ entry.nickname || entry.first_name || 'System' }} {{ entry.details || entry.action }}

New Pay Period

Creates period for the current week (Monday - Sunday).

Record Payment

{{ payrollPayingItem.nickname || payrollPayingItem.first_name }} — ${{ Number(payrollPayingItem.gross_amount).toFixed(2) }}

Schedule

No schedule window available.

This week has already been worked

Shifts on days payroll has not closed can still be corrected or removed here. New shifts go on the open rota — {{ scheduleEditableRange.label }}, never on a week already behind us.

Payroll has closed every day of this week

Nothing can be added, changed or removed here while it is closed. Reopen the pay period if this week still needs work on it.

{{ scheduleWeekLock.allLocked ? 'This whole week is locked' : 'Part of this week is locked' }}

Payroll has been closed for {{ scheduleWeekLock.label }}, so {{ scheduleWeekLock.allLocked ? 'every day of this week is read-only' : 'those days are read-only — the rest of the week can still be changed' }}. Reopen that pay period in Payroll if a locked day has to change.

{{ scheduleWeekLabel(scheduleActiveWeek) }}

Showing {{ scheduleVisibleCount }} of {{ scheduleWeekCount }} shift{{ scheduleWeekCount === 1 ? '' : 's' }} · {{ scheduleEmptyDayCount }} day{{ scheduleEmptyDayCount === 1 ? '' : 's' }} with nobody on

Loading the rota…

{{ day.weekday }} {{ day.pretty }} Today Locked

{{ scheduleStaffFilter ? 'Not scheduled' : 'Nobody was on this day' }}

Nobody on this day

Not scheduled

{{ day.shifts.length }} shift{{ day.shifts.length === 1 ? '' : 's' }} · peak {{ day.peak.count }} of {{ SCHEDULE_MAX_CONCURRENT }} people at the same time

Full from {{ formatTime12h(day.peak.at) }} — {{ day.peak.names.join(', ') }} are all on. A fourth person will be rejected.

{{ s.user_name }}

{{ formatTime12h(s.start_time) }} – {{ formatTime12h(s.end_time) }} · {{ s.location_name }}

{{ s.note }}

Locked
Pick a week above to see the rota.

Expenses

Total

${{ Number(expenseReport.totals?.total || 0).toFixed(2) }}

Count

{{ expenseReport.totals?.count || 0 }}

{{ cat.category }}

${{ Number(cat.total).toFixed(2) }}

Date Vendor Category Amount Method Actions
{{ exp.expense_date }} {{ exp.vendor || '—' }} {{ exp.category }} ${{ Number(exp.amount).toFixed(2) }} {{ exp.payment_method }}
No expenses found

{{ editingExpense ? 'Edit' : 'Add' }} Expense

AI-scanned receipt data — review and adjust before saving

Till Count

{{ tillSelectedSession.session_date }} — {{ tillSelectedSession.shift }}

{{ tillSelectedSession.status }}

Starting Cash

${{ Number(tillSelectedSession.starting_cash || 0).toFixed(2) }}

${{ Number(tillSelectedSession.pos_cash_sales_total || 0).toFixed(2) }}
${{ Number(tillSelectedSession.cash_drop_amount || 0).toFixed(2) }}

Denomination Count

{{ d.label }}
{{ d.qty }}
${{ (d.value * d.qty).toFixed(2) }}
Total: ${{ tillDenominations.reduce((sum, d) => sum + d.value * d.qty, 0).toFixed(2) }}

Paid Outs

{{ po.time }} — {{ po.description }} ({{ po.employee_initial }})
${{ Number(po.amount).toFixed(2) }}

Receipt Paid Outs

{{ rpo.receipt_store }} — {{ rpo.description }}
${{ Number(rpo.amount).toFixed(2) }}

Notes

{{ tillSelectedSession.notes || 'No notes' }}

Summary

Starting Cash
${{ Number(tillSelectedSession.starting_cash || 0).toFixed(2) }}
+ POS Cash Sales
${{ Number(tillSelectedSession.pos_cash_sales_total || 0).toFixed(2) }}
- Cash Drops
${{ Number(tillSelectedSession.cash_drop_amount || 0).toFixed(2) }}
- Paid Outs
${{ Number(tillSelectedSession.paid_outs_total || 0).toFixed(2) }}
- Receipt Paid Outs
${{ Number(tillSelectedSession.receipt_paid_outs_total || 0).toFixed(2) }}
Expected Cash
${{ Number(tillSelectedSession.expected_cash_in_drawer || 0).toFixed(2) }}
Actual Cash (denominations)
${{ tillDenominations.reduce((sum, d) => sum + d.value * d.qty, 0).toFixed(2) }}
Over/Short {{ tillSelectedSession.over_short_status || (Number(tillSelectedSession.over_short_amount) > 0 ? 'OVER' : Number(tillSelectedSession.over_short_amount) < 0 ? 'SHORT' : 'EVEN') }}
${{ Number(tillSelectedSession.over_short_amount || 0).toFixed(2) }}

{{ tillSelectedSession.status === 'open' ? 'Employee Signature' : 'Manager Signature' }}

Employee signature Manager signature

Verified by {{ tillSelectedSession.verified_by_name || 'Manager' }}

{{ tillSelectedSession.finalized_at ? new Date(tillSelectedSession.finalized_at).toLocaleString() : (tillSelectedSession.locked_at ? new Date(tillSelectedSession.locked_at).toLocaleString() : '') }}

Employee

Employee signature

Manager

Manager signature

Awaiting Manager Verification

Submitted {{ tillSelectedSession.submitted_at ? new Date(tillSelectedSession.submitted_at).toLocaleString() : '' }}

Employee Signature

Employee signature

No active till session for today

Date Shift Opened By Over/Short Status Actions
{{ session.session_date }} {{ session.shift?.replace('_', ' ') }} {{ session.nickname || session.first_name || '—' }} ${{ Number(session.over_short_amount || 0).toFixed(2) }} {{ session.status }}
No till sessions found

New Till Session

Console · v{{ APP_VERSION }}

{{ user?.nickname || user?.first_name }} — {{ user?.role }}

Pick a location to see what you can do here.

No apps are enabled for your account at this location.

{{ group.label }}

Staff Management

Name Role PIN Status Last Login Actions
{{ (member.nickname || member.first_name || 'U').charAt(0).toUpperCase() }}

{{ member.nickname || (member.first_name + ' ' + member.last_name) }}

{{ member.email }}

{{ member.role }}
Active Inactive {{ timeSince(member.last_login) }}
No staff members found
Loading…
No events match this filter.
When Who Action IP
{{ formatFullTimestamp(ev.created_at) }} {{ ev.user_name || (ev.user_id ? ('User #' + ev.user_id) : '—') }} {{ loginActionLabel(ev.action) }} {{ activityTarget(ev) }} {{ ev.ip_address || '—' }}

Invite New Staff

Generate an invite code that new staff can use to create their account.

Share this code with the new staff member:

{{ generatedInviteCode }}

Valid for 7 days. They can use this on the login page.

Suggestion Box

No open suggestions
{{ s.nickname || s.first_name }} {{ new Date(s.created_at).toLocaleString() }}

{{ s.message }}

No reviewed suggestions
{{ s.nickname || s.first_name }} {{ new Date(s.created_at).toLocaleString() }}

{{ s.message }}

Reply by {{ s.replied_nickname || s.replied_first_name }} — {{ s.replied_at ? new Date(s.replied_at).toLocaleString() : '' }}

{{ s.admin_reply }}

Submit a Suggestion

Replies to your suggestions:

Your suggestion: {{ n.message.length > 80 ? n.message.slice(0, 80) + '...' : n.message }}

{{ n.admin_reply }}

Get Stock from Main

Pick how many of each you're taking to {{ selectedLocation?.name || 'this location' }}.

Loading…
No items are shared by Main yet. Ask an admin to flag items as "Share with other locations" from the inventory editor.
No items match "{{ borrowSearch }}"

{{ it.name }}

Main has {{ parseFloat(it.owner_quantity).toFixed(it.unit === 'each' ? 0 : 1) }} {{ it.unit }} · You have {{ parseFloat(it.borrowed_quantity).toFixed(it.unit === 'each' ? 0 : 1) }}

{{ editingStaff ? 'Edit Staff' : 'Add Staff' }}

A new PIN will be assigned and shown after saving.

A unique PIN will be auto-generated and shown after creating the staff member.

Sets the overtime rate

From their 499 R-4.1

Hired before 26 Jan 2017 — keeps the pre-reform rate on daily overtime.

Daily overtime at 1.5×.

No hire date — overtime will default to 1.5×. Set it before the first real payroll run.

Apply everywhere
Per location
Copy from:
Select a location tab above to edit its permissions.

{{ pinResult.title }}

{{ pinResult.name }}

PIN

{{ pinResult.pin }}

Complete Payment

Order #{{ payingOrder.order_number }}

Payment received

Change due to customer

${{ lastChangeDue.toFixed(2) }}

Customer pays here

Ask them to scan the QR on the counter, or share the link:

Pay link

{{ stripeQR.payUrl }}

Waiting for payment...

Order #{{ selectedOrder?.order_number }}

{{ selectedOrder?.created_at }}

👤

{{ selectedOrder.customer_name }}

🏠 Vieques Resident

No customer attached

Status

{{ selectedOrder.status }}

Payment

{{ selectedOrder.payment_status }}

Type

{{ selectedOrder.order_type?.replace('_', ' ') }}

Items

{{ item.product_name }}

{{ item.quantity }} x ${{ parseFloat(item.unit_price).toFixed(2) }}

${{ parseFloat(item.subtotal).toFixed(2) }}

Subtotal ${{ parseFloat(selectedOrder.subtotal).toFixed(2) }}
Discount -${{ parseFloat(selectedOrder.discount_amount).toFixed(2) }}
Loyalty Reward -${{ parseFloat(selectedOrder.loyalty_discount_amount).toFixed(2) }}
Tax ${{ parseFloat(selectedOrder.tax_amount).toFixed(2) }}
Total ${{ parseFloat(selectedOrder.total).toFixed(2) }}

{{ editingProduct ? 'Edit Product' : 'Add Product' }}

📷

JPG, PNG or WebP. Max 2MB.

Kitchen recipe: {{ editingProduct.recipe_name }}

Stock: {{ parseFloat(editingProduct.inventory_stock || 0).toFixed(1) }} {{ editingProduct.inventory_unit }} ({{ editingProduct.inventory_item_name }})

No stock tracked yet — use Add Inventory to set an initial count.

Choose an icon

Pick from the groups below.

{{ editingCategory ? 'Edit Category' : 'Add Category' }}

{{ categoryForm.name || 'Category Name' }}

{{ categoryForm.name_es || 'Nombre de Categoría' }}

Quick Add Customer

{{ editingCustomer ? 'Edit Customer' : 'Add Customer' }}

If set, this will display instead of their name

{{ emailError }}

{{ phoneError }}

{{ editingInventory ? 'Edit Inventory Item' : 'Add Inventory Item' }}

Inventory Details

Add Inventory

{{ selectedLocation ? selectedLocation.name : 'All Locations' }}

Loading products...

{{ catName }}

{{ row.product_name }}

Current: {{ parseFloat(row.current_stock).toFixed(0) }} 🍳

No additions recorded yet.

{{ entry.product_name || entry.item_name }}

{{ entry.notes }}

+{{ entry.quantity_change }}

{{ new Date(entry.created_at).toLocaleDateString() }} {{ new Date(entry.created_at).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) }}

Order History

{{ viewingCustomer?.nickname || (viewingCustomer?.first_name + ' ' + viewingCustomer?.last_name) }}

No orders found

#{{ order.order_number }}

{{ new Date(order.created_at).toLocaleDateString() }} at {{ formatTime(order.created_at) }}

{{ order.item_count }} items

${{ parseFloat(order.total).toFixed(2) }}

{{ order.status }}
Total Orders: {{ viewingCustomer?.total_orders || 0 }} Lifetime Spent: ${{ parseFloat(viewingCustomer?.total_spent || 0).toFixed(2) }}

{{ editingLoyaltyRule ? 'Edit' : 'Add' }} Loyalty Rule

{{ loyaltyRewardProduct.name }} costs ${{ parseFloat(loyaltyRewardProduct.price).toFixed(2) }} but max reward is ${{ parseFloat(loyaltyRuleForm.max_reward_value).toFixed(2) }}. Customer pays the ${{ (parseFloat(loyaltyRewardProduct.price) - parseFloat(loyaltyRuleForm.max_reward_value)).toFixed(2) }} difference. {{ loyaltyRewardProduct.name }} (${{ parseFloat(loyaltyRewardProduct.price).toFixed(2) }}) is fully covered.

{{ editingNote ? 'Edit Note' : 'Add Business Note' }}

Where are you?

You're at {{ selectedLocation.name }}. Tap another to move.

No location selected yet — pick one.

No active locations yet.
Add or activate one in Settings → Locations.
{{ toast.message }} tap to copy copied!