Files
eamco_office_frontend/src/pages/customer/profile/profile.vue
Edwin Eames 1a53e50d91 feat: 5-tier pricing UI, market ticker, delivery map, and stats dashboard
Full frontend companion to the API updates:

- Pricing: Oil price admin page now supports 5-tier configuration for
  same-day/prime/emergency fees with collapsible tier sections
- Market Ticker: Add GlobalMarketTicker and OilPriceTicker components
  with real-time commodity + competitor prices in header bar
- Delivery Map: New interactive Leaflet map view for daily deliveries
- Stats: Add PricingHistoryChart component and info pages for market
  trends with daily/weekly/monthly gallon charts and YoY comparisons
- Layout: Refactor header navbar to separate search into navbar-center,
  add oilPrice Pinia store with polling, update sidebar navigation
- Forms: Wire tier selection into delivery create/edit flows, update
  types and services for new pricing and scraper API endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 17:54:30 -05:00

1051 lines
39 KiB
Vue
Executable File

<!-- src/pages/customer/profile/profile.vue -->
<template>
<div class="w-full min-h-screen px-4 md:px-10">
<!-- ... breadcrumbs ... -->
<div v-if="customer && customer.id" class="mt-6">
<!-- Current Plan Status Banner - Same as ServicePlanEdit.vue -->
<div v-if="servicePlan && servicePlan.contract_plan > 0" class="alert alert-info mb-6"
:class="servicePlan.contract_plan === 2 ? 'border-4 border-yellow-400 bg-yellow-50' : ''">
<div class="flex items-center">
<div class="flex-1">
<div class="flex items-center gap-3">
<h3 class="font-bold">Current Plan: {{ getPlanName(servicePlan.contract_plan) }}</h3>
<!-- Premium Star Icon -->
<svg v-if="servicePlan.contract_plan === 2" class="w-8 h-8 text-yellow-500 fill-current" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<p>{{ servicePlan.contract_years }} Year{{ servicePlan.contract_years > 1 ? 's' : '' }} Contract</p>
<p class="text-sm">Expires: {{ formatEndDate(servicePlan.contract_start_date, servicePlan.contract_years) }}
</p>
</div>
<div class="badge" :class="getStatusBadge(servicePlan.contract_start_date, servicePlan.contract_years)">
{{ getStatusText(servicePlan.contract_start_date, servicePlan.contract_years) }}
</div>
</div>
</div>
<!-- FIX: Changed `lg:` to `xl:` for a later breakpoint -->
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6">
<!-- FIX: Changed `lg:` to `xl:` -->
<div class="xl:col-span-8 space-y-6">
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6">
<ProfileMap v-if="customer && customer.customer_latitude != null && customer.customer_longitude != null"
class="xl:col-span-7" :customer="customer" />
<!-- You can add a placeholder for when the map isn't ready -->
<div v-else class="xl:col-span-7 card-glass flex justify-center items-center">
<p class="text-gray-400">Location not available...</p>
</div>
<div class="xl:col-span-5 space-y-6">
<ProfileSummary :customer="customer" :automatic_status="automatic_status"
:customer_description="customer_description.description" @toggle-automatic="userAutomatic" />
<CustomerStats :stats="customer_stats" :last_delivery="customer_last_delivery" />
</div>
</div>
<HistoryTabs :deliveries="deliveries" :autodeliveries="autodeliveries" :service-calls="serviceCalls"
:transactions="transactions" @open-service-modal="openEditModal" />
</div>
<!-- FIX: Changed `lg:` to `xl:` -->
<div class="xl:col-span-4 space-y-6">
<!-- Authorize.net Account Status Box -->
<div v-if="customer.id" class="card-glass p-4">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-center gap-3 mb-3 md:mb-0">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v4a3 3 0 003 3z" />
</svg>
<span v-if="isLoadingAuthorize" class="text-sm font-medium">
<span class="loading loading-dots loading-xs mr-2"></span>
Loading...
</span>
<span v-else-if="authorizeCheck.valid_for_charging" class="text-sm font-medium">
Authorize Account ID: {{ customer.auth_net_profile_id }}
</span>
<span v-else class="text-sm font-medium text-red-600">
{{ getAccountStatusMessage() }}
</span>
</div>
<div class="flex gap-2" v-if="!isLoadingAuthorize && !authorizeCheck.valid_for_charging">
<!-- CREATE ACCOUNT SECTION - Only show when account doesn't exist -->
<div class="flex gap-2">
<button @click="createAuthorizeAccount"
:class="['btn btn-sm', credit_cards_count === 0 ? 'btn-disabled' : 'btn-primary']"
:disabled="credit_cards_count === 0" v-if="credit_cards_count > 0">
Create Account
</button>
<button v-else @click="addCreditCard" class="btn btn-secondary btn-sm">
Add Card First
</button>
</div>
</div>
<!-- DELETE ACCOUNT SECTION - Only show when account exists -->
<div v-if="authorizeCheck.valid_for_charging" class="mt-3 lg:mt-0 lg:flex lg:gap-2">
<button @click="showDeleteAccountModal" class="btn btn-error btn-sm lg:self-start">
Delete Account
</button>
</div>
</div>
</div>
<TankEstimation :customer-id="customer.id" />
<CustomerComments :comments="comments" @add-comment="onSubmitSocial" @delete-comment="deleteCustomerSocial" />
<TankInfo :customer_id="customer.id" :tank="customer_tank" :description="customer_description" :estimation="autoEstimation" />
<EquipmentParts :parts="currentParts" @open-parts-modal="openPartsModal" />
<CreditCards :cards="credit_cards" :count="credit_cards_count" :user_id="customer.id"
:auth_net_profile_id="customer.auth_net_profile_id" @edit-card="editCard" @remove-card="removeCard" />
<!-- Automatic Delivery Actions Box -->
<div v-if="automatic_status === 1 && autodeliveries.length > 0" class="card-glass p-4">
<h3 class="font-semibold mb-4">Automatic Delivery Actions</h3>
<div class="flex flex-wrap gap-2">
<router-link v-if="autodeliveries[0].auto_status != 3"
:to="{ name: 'payAutoAuthorize', params: { id: autodeliveries[0].id } }">
<button class="btn btn-primary btn-sm">Preauthorize</button>
</router-link>
<router-link :to="{ name: 'finalizeTicketAutoNocc', params: { id: autodeliveries[0].id } }">
<button class="btn btn-secondary btn-sm">Finalize</button>
</router-link>
<router-link v-if="autodeliveries[0].auto_status == 3"
:to="{ name: 'finalizeTicketAuto', params: { id: autodeliveries[0].open_ticket_id || autodeliveries[0].id } }">
<button class="btn btn-secondary btn-sm">Finalize</button>
</router-link>
<router-link :to="{ name: 'TicketAuto', params: { id: autodeliveries[0].id } }">
<button class="btn btn-success btn-sm">Print Ticket</button>
</router-link>
</div>
</div>
</div>
</div>
</div>
<!-- A loading indicator is shown while the API call is in progress -->
<div v-else class="flex justify-center items-center mt-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- The Footer can be placed here if it's specific to this page -->
</div>
<!-- Modals remain at the root of the template for proper display -->
<ServiceEditModal v-if="selectedServiceForEdit" :service="selectedServiceForEdit" @close-modal="closeEditModal"
@save-changes="handleSaveChanges" @delete-service="handleDeleteService" />
<PartsEditModal v-if="isPartsModalOpen && currentParts" :customer-id="customer.id" :existing-parts="currentParts"
@close-modal="closePartsModal" @save-parts="handleSaveParts" />
<!-- Delete Account Confirmation Modal -->
<div class="modal" :class="{ 'modal-open': isDeleteAccountModalVisible }">
<div class="modal-box">
<h3 class="font-bold text-lg">Confirm Account Deletion</h3>
<p class="py-4">This will permanently delete the Authorize.net account and remove all payment profiles. This
action cannot be undone.</p>
<div class="modal-action">
<button @click="deleteAccount" class="btn btn-error">Delete Account</button>
<button @click="isDeleteAccountModalVisible = false" class="btn">Cancel</button>
</div>
</div>
</div>
<!-- Create Account Progress Modal -->
<div class="modal" :class="{ 'modal-open': isCreateAccountModalVisible }">
<div class="modal-box">
<h3 class="font-bold text-lg">Creating Authorize.net Account</h3>
<div class="py-4 flex flex-col items-center">
<div v-if="isCreatingAccount" class="text-center">
<span class="text-lg mb-3">Setting up your payment account...</span>
<div class="loading loading-spinner loading-lg text-primary mb-3"></div>
<p class="text-sm text-gray-600">Please wait while we create your Authorize.net customer profile.</p>
</div>
<div v-else class="text-center">
<div class="text-success mb-3">
<svg class="w-12 h-12 mx-auto mb-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd" />
</svg>
</div>
<p class="text-lg font-semibold mb-2">Account Created Successfully!</p>
<div class="bg-base-200 p-3 rounded-lg mb-4">
<p class="text-sm mb-1">Authorize.net Profile ID:</p>
<p class="font-mono font-bold text-success">{{ createdProfileId }}</p>
</div>
<p class="text-sm text-gray-600">Your payment account is now ready for transactions.</p>
</div>
</div>
</div>
</div>
<!-- Duplicate Account Error Modal -->
<div class="modal" :class="{ 'modal-open': isDuplicateErrorModalVisible }">
<div class="modal-box">
<h3 class="font-bold text-lg text-error">⚠️ Duplicate Account Detected</h3>
<div class="py-4 space-y-4">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="text-lg font-semibold">Duplicate Account in Authorize.net</p>
<p class="text-sm text-gray-600 mt-2">
A duplicate account was found in your Authorize.net merchant account.
</p>
<p class="text-sm text-gray-600 mt-2">
Customer ID: <strong>{{ customer.id }}</strong>
</p>
</div>
<div class="bg-base-200 p-4 rounded-lg">
<h4 class="font-semibold mb-2 text-warning">Action Required:</h4>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Manually check your Authorize.net merchant dashboard</li>
<li>Review existing customer profiles</li>
<li>Contact support for linkage if needed</li>
</ul>
<p class="text-xs text-gray-500 mt-2">
Inconsistency between your system and Authorize.net detected.
</p>
</div>
<div class="text-center pt-2">
<p class="text-xs text-gray-500">
This profile may have been created previously and needs manual linking.
</p>
</div>
</div>
<div class="modal-action">
<button class="btn btn-primary" @click="hideDuplicateErrorModal()">
Acknowledge
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { authService } from '../../../services/authService'
import { customerService } from '../../../services/customerService'
import { paymentService } from '../../../services/paymentService'
import { deliveryService } from '../../../services/deliveryService'
import { serviceService } from '../../../services/serviceService'
import { adminService } from '../../../services/adminService'
import Header from '../../../layouts/headers/headerauth.vue'
import SideBar from '../../../layouts/sidebar/sidebar.vue'
import { notify } from "@kyvg/vue3-notification";
import "leaflet/dist/leaflet.css";
import L from 'leaflet';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
import { LMap, LTileLayer } from "@vue-leaflet/vue-leaflet";
import dayjs from 'dayjs';
import ServiceEditModal from '../../service/ServiceEditModal.vue';
import PartsEditModal from '../service/PartsEditModal.vue';
// Import new child components
import ProfileMap from './profile/ProfileMap.vue';
import ProfileSummary from './profile/ProfileSummary.vue';
import CustomerStats from './profile/CustomerStats.vue';
import TankInfo from './profile/TankInfo.vue';
import EquipmentParts from './profile/EquipmentParts.vue';
import CreditCards from './profile/CreditCards.vue';
import CustomerComments from './profile/CustomerComments.vue';
import HistoryTabs from './profile/HistoryTabs.vue';
import TankEstimation from './TankEstimation.vue';
import { AuthorizeTransaction, PricingData, CustomerDescriptionData, CustomersResponse, CustomerResponse, AxiosResponse, AxiosError } from '../../../types/models';
L.Icon.Default.mergeOptions({
iconUrl: iconUrl,
shadowUrl: shadowUrl,
});
interface Delivery {
id: number;
delivery_status: number;
customer_name: string;
customer_asked_for_fill: number | boolean;
gallons_ordered: number | string;
gallons_delivered: number | string | null;
expected_delivery_date: string;
}
interface AutomaticDelivery {
id: number;
customer_full_name: string;
gallons_delivered: number | string;
fill_date: string;
auto_status: number | string;
open_ticket_id: number | string;
}
interface CreditCard {
id: number;
main_card: boolean;
type_of_card: string;
name_on_card: string;
card_number: string;
expiration_month: number;
expiration_year: string | number;
zip_code: string;
security_number: string;
}
// You already have these, just make sure they exist
interface ServiceCall {
id: number;
scheduled_date: string;
customer_name: string;
customer_address: string;
customer_town: string;
type_service_call: number;
description: string;
}
interface ServiceParts {
id?: number;
customer_id: number;
oil_filter: string;
oil_filter_2: string;
oil_nozzle: string;
oil_nozzle_2: string;
hot_water_tank: number;
}
interface ServicePlan {
id: number;
customer_id: number;
contract_plan: number;
contract_years: number;
contract_start_date: string;
}
// Router and route
const route = useRoute()
const router = useRouter()
// Reactive data
const zoom = ref(14)
const user = ref(null as { user_id: number; user_name: string; confirmed: string; } | null)
const automatic_status = ref(0)
const customer_last_delivery = ref('')
const comments = ref([{ id: 0, created: '', customer_id: 0, poster_employee_id: 0, comment: '' }])
const CreateSocialForm = ref({ basicInfo: { comment: '' } })
// --- UPDATE THESE LINES ---
const credit_cards = ref([] as CreditCard[])
const deliveries = ref([] as Delivery[])
const autodeliveries = ref([] as AutomaticDelivery[])
const serviceCalls = ref([] as ServiceCall[])
const transactions = ref([] as AuthorizeTransaction[])
// --- END OF UPDATES ---
const autoEstimation = ref<{ confidence_score: number; k_factor_source: string; days_remaining: number } | undefined>(undefined)
const automatic_response = ref(0)
const credit_cards_count = ref(0)
const customer = ref({ id: 0, user_id: null as number | null, customer_first_name: '', customer_last_name: '', customer_town: '', customer_address: '', customer_state: 0, customer_zip: '', customer_apt: '', customer_home_type: 0, customer_phone_number: '', customer_latitude: 0, customer_longitude: 0, correct_address: true, account_number: '', auth_net_profile_id: null })
const customer_description = ref({ id: 0, customer_id: 0, account_number: '', company_id: '', fill_location: 0, description: '' })
const customer_tank = ref({ id: 0, last_tank_inspection: null, tank_status: false, outside_or_inside: false, tank_size: 0 })
const customer_stats = ref({ id: 0, customer_id: 0, total_calls: 0, service_calls_total: 0, service_calls_total_spent: 0, service_calls_total_profit: 0, oil_deliveries: 0, oil_total_gallons: 0, oil_total_spent: 0, oil_total_profit: 0 })
const delivery_page = ref(1)
const selectedServiceForEdit = ref(null as ServiceCall | null)
const isPartsModalOpen = ref(false)
const currentParts = ref(null as ServiceParts | null)
const servicePlan = ref(null as ServicePlan | null)
const isLoadingAuthorize = ref(true)
const authorizeCheck = ref({ profile_exists: false, has_payment_methods: false, missing_components: [] as string[], valid_for_charging: false })
const isDeleteAccountModalVisible = ref(false)
const isCreateAccountModalVisible = ref(false)
const isCreatingAccount = ref(false)
const createdProfileId = ref('')
const isDuplicateErrorModalVisible = ref(false) // Add for duplicate detection popup
const pricing = ref<PricingData>({
price_from_supplier: 0,
price_for_customer: 0,
price_for_employee: 0,
price_same_day: 0,
price_prime: 0,
price_emergency: 0,
date: ""
})
// Computed
const hasPartsData = computed(() => {
if (!currentParts.value) return false;
return !!(currentParts.value.oil_filter || currentParts.value.oil_filter_2 || currentParts.value.oil_nozzle || currentParts.value.oil_nozzle_2);
})
// Lifecycle
onMounted(() => {
getCustomer(route.params.id);
})
// Watchers
watch(() => route.params.id, (newId) => {
if (newId) {
getCustomer(newId);
}
})
// Mounted lifecycle
onMounted(() => {
// Check for success query parameter and show notification
if (route.query.success === 'true') {
notify({ title: "Success", text: "Customer edited successfully!", type: "success" });
// Clean up the URL by removing the query parameter
router.replace({ name: route.name, params: route.params, query: {} });
}
})
// Functions
const getPage = (page: number) => {
if (customer.value && customer.value.id) {
getCustomerDelivery(customer.value.id, page);
}
}
const getCustomer = (userid: number) => {
if (!userid) return;
customerService.getById(userid).then((response: AxiosResponse<any>) => {
// Correctly handle response structure - backend may return wrapped { customer: ... } or flat
const data = response.data;
customer.value = data.customer || data;
// Handle pricing - it might be missing or nested
if (data.pricing) {
pricing.value = data.pricing;
}
// --- DEPENDENT API CALLS ---
userStatus();
// FIX: Pass the correct ID for payment-related calls
getCreditCards(customer.value.id);
getCreditCardsCount(customer.value.id);
// These other calls are likely correct as they are customer-specific
getCustomerSocial(customer.value.id, 1);
getPage(delivery_page.value);
checktotalOil(customer.value.id);
getCustomerTank(customer.value.id);
userAutomaticStatus(customer.value.id);
getCustomerDescription(customer.value.id);
getCustomerStats(customer.value.id);
getCustomerLastDelivery(customer.value.id);
getServiceCalls(customer.value.id);
fetchCustomerParts(customer.value.id);
loadServicePlan(customer.value.id);
getCustomerTransactions(customer.value.id);
checkAuthorizeAccount();
}).catch((err: unknown) => {
const error = err as AxiosError;
console.error("CRITICAL: Failed to fetch main customer data. Aborting other calls.", error);
});
}
const userStatus = () => {
authService.whoami().then((response: any) => {
if (response.data.ok) {
user.value = response.data.user;
}
}).catch(() => { user.value = null });
}
const userAutomaticStatus = (userid: number) => {
customerService.getAutomaticStatus(userid).then((response: AxiosResponse<any>) => {
automatic_status.value = response.data.status
if (automatic_status.value === 1) {
getCustomerAutoDelivery(customer.value.id)
}
checktotalOil(customer.value.id)
})
}
const userAutomatic = (userid: number) => {
// Toggle status: 1 -> 0, 0 -> 1
const newStatus = automatic_status.value === 1 ? 0 : 1;
customerService.assignAutomatic(userid, { status: newStatus }).then((response: AxiosResponse<any>) => {
// Update local status from response or the requested value
if (response.data && typeof response.data.status !== 'undefined') {
automatic_status.value = response.data.status;
} else {
automatic_status.value = newStatus;
}
if (automatic_status.value === 1) {
getCustomerAutoDelivery(customer.value.id);
}
checktotalOil(customer.value.id);
notify({
title: "Automatic Status Updated",
text: automatic_status.value === 1 ? "Customer set to Automatic" : "Customer set to Will Call",
type: "success"
});
}).catch((err: unknown) => {
console.error("Failed to update automatic status", err);
notify({ title: "Error", text: "Failed to update status", type: "error" });
});
}
const getNozzleColor = (nozzleString: string): string => {
if (!nozzleString || typeof nozzleString !== 'string') return '';
const firstChar = nozzleString.trim().toLowerCase().charAt(0);
switch (firstChar) {
case 'a': return '#EF4444';
case 'b': return '#3B82F6';
case 'w': return '#16a34a';
default: return 'inherit';
}
}
const getCustomerLastDelivery = (userid: number) => {
adminService.stats.userLastDelivery(userid).then((response: AxiosResponse<any>) => {
customer_last_delivery.value = response.data.date
})
}
const getCustomerStats = (userid: number) => {
adminService.stats.userStats(userid).then((response: AxiosResponse<any>) => {
customer_stats.value = response.data
})
}
const checktotalOil = (userid: number) => {
adminService.stats.customerGallonsTotal(userid) // Just a check
}
const getCustomerDescription = (userid: number) => {
customerService.getDescription(userid).then((response: AxiosResponse<any>) => {
customer_description.value = response.data?.description || (response.data as unknown as CustomerDescriptionData);
})
}
const getCustomerTank = (userid: number) => {
customerService.getTank(userid).then((response: AxiosResponse<any>) => {
customer_tank.value = response.data
})
}
const getCreditCards = (user_id: number) => {
paymentService.getCards(user_id).then((response: AxiosResponse<any>) => {
credit_cards.value = response.data?.cards || []
})
}
const getCreditCardsCount = (user_id: number) => {
paymentService.getCardsOnFile(user_id).then((response: AxiosResponse<any>) => {
credit_cards_count.value = response.data.cards
})
}
const getCustomerAutoDelivery = (userid: number) => {
deliveryService.auto.getProfileDeliveries(userid).then((response: AxiosResponse<any>) => {
autodeliveries.value = response.data || []
})
// Also fetch the auto delivery record for estimation data
deliveryService.auto.getByCustomer(userid).then((response: AxiosResponse<any>) => {
if (response.data && response.data.id) {
autoEstimation.value = {
confidence_score: response.data.confidence_score ?? 20,
k_factor_source: response.data.k_factor_source ?? 'default',
days_remaining: response.data.days_remaining ?? 999
}
}
}).catch(() => {
autoEstimation.value = undefined
})
}
const getCustomerDelivery = (userid: number, delivery_page: number) => {
deliveryService.getByCustomer(userid, delivery_page).then((response: AxiosResponse<any>) => {
deliveries.value = response.data?.deliveries || []
})
}
const editCard = (card_id: number) => {
router.push({ name: "cardedit", params: { id: card_id } });
}
const removeCard = (card_id: number) => {
paymentService.removeCard(card_id).then(() => {
credit_cards.value = credit_cards.value.filter(card => card.id !== card_id);
credit_cards_count.value--;
notify({ title: "Card Status", text: "Card Removed", type: "success" });
}).catch(() => {
notify({ title: "Error", text: "Could not remove card.", type: "error" });
});
}
const deleteCall = (delivery_id: number) => {
deliveryService.delete(delivery_id).then((response: AxiosResponse<any>) => {
if (response.data.ok) {
notify({ title: "Success", text: "deleted delivery", type: "success" });
getPage(1)
} else {
notify({ title: "Failure", text: "error deleting delivery", type: "success" });
}
})
}
const deleteCustomerSocial = (comment_id: number) => {
adminService.social.deletePost(comment_id).then((response: AxiosResponse<any>) => {
getCustomerSocial(customer.value.id, 1)
})
}
const getCustomerSocial = (userid: number, delivery_page: number) => {
adminService.social.getPosts(userid, delivery_page).then((response: AxiosResponse<any>) => {
comments.value = response.data?.posts || []
})
}
const CreateSocialComment = (payload: { comment: string; poster_employee_id: number }) => {
adminService.social.createPost(customer.value.id, payload).then((response: AxiosResponse<any>) => {
if (response.data.ok) {
getCustomerSocial(customer.value.id, 1)
} else if (response.data.error) {
router.push("/");
}
})
}
const onSubmitSocial = (commentText: string) => {
if (!user.value) {
console.error("Cannot submit comment: user is not logged in.");
return;
}
let payload = { comment: commentText, poster_employee_id: user.value.user_id };
CreateSocialComment(payload);
}
const getServiceCalls = (customerId: number) => {
serviceService.getForCustomer(customerId).then((response: AxiosResponse<any>) => {
serviceCalls.value = response.data?.services || [];
}).catch((error: any) => {
console.error("Failed to get customer service calls:", error);
serviceCalls.value = [];
});
}
const getCustomerTransactions = (customerId: number) => {
paymentService.getCustomerTransactions(customerId, 1).then((response: AxiosResponse<any>) => {
transactions.value = response.data?.transactions || [];
}).catch((error: any) => {
console.error("Failed to get customer transactions:", error);
transactions.value = [];
});
}
const openEditModal = (service: ServiceCall) => {
selectedServiceForEdit.value = service;
}
const closeEditModal = () => {
selectedServiceForEdit.value = null;
}
const handleSaveChanges = async (updatedService: ServiceCall) => {
try {
const response = await serviceService.update(updatedService.id, updatedService);
getServiceCalls(customer.value.id);
closeEditModal();
} catch (error) {
console.error("Failed to save service call changes:", error);
}
}
const handleDeleteService = async (serviceId: number) => {
if (!window.confirm("Are you sure you want to delete this service call?")) return;
try {
const response = await serviceService.delete(serviceId);
if (response.data.ok) {
getServiceCalls(customer.value.id);
closeEditModal();
notify({ title: "Success", text: "Service call deleted!", type: "success" });
}
} catch (error) {
console.error("Failed to delete service call:", error);
}
}
const fetchCustomerParts = async (customerId: number) => {
try {
const response = await serviceService.getPartsForCustomer(customerId);
if (response.data && 'parts' in response.data && Array.isArray(response.data.parts) && response.data.parts.length > 0) {
currentParts.value = response.data.parts[0];
} else {
currentParts.value = null;
}
} catch (error) {
console.error("Failed to fetch customer parts:", error);
}
}
const openPartsModal = () => {
if (currentParts.value) {
isPartsModalOpen.value = true;
} else {
notify({ title: "Info", text: "Parts data still loading, please wait.", type: "info" });
}
}
const closePartsModal = () => {
isPartsModalOpen.value = false;
}
const handleSaveParts = async (partsToSave: Partial<ServiceParts>) => {
try {
if (!partsToSave.customer_id) throw new Error("Customer ID is missing");
const response = await serviceService.updateParts(partsToSave.customer_id, partsToSave);
if (response.data.ok) {
currentParts.value = partsToSave as ServiceParts;
notify({ title: "Success", text: "Equipment parts saved successfully!", type: "success" });
}
closePartsModal();
} catch (error) {
console.error("Failed to save parts:", error);
notify({ title: "Error", text: "Failed to save equipment parts.", type: "error" });
}
}
const formatDate = (dateString: string): string => {
if (!dateString) return 'N/A';
return dayjs(dateString).format('MMMM D, YYYY');
}
const formatTime = (dateString: string): string => {
if (!dateString) return 'N/A';
return dayjs(dateString).format('h:mm A');
}
const getServiceTypeName = (typeId: number): string => {
const typeMap: { [key: number]: string } = { 0: 'Tune-up', 1: 'No Heat', 2: 'Fix', 3: 'Tank Install', 4: 'Other' };
return typeMap[typeId] || 'Unknown Service';
}
const getServiceTypeColor = (typeId: number): string => {
const colorMap: { [key: number]: string } = { 0: 'blue', 1: 'red', 2: 'green', 3: '#B58900', 4: 'black' };
return colorMap[typeId] || 'gray';
}
const formatEndDate = (startDate: string, years: number): string => {
if (!startDate) return 'N/A';
return dayjs(startDate).add(years, 'year').format('MMM D, YYYY');
}
const getPlanStatusText = (startDate: string, years: number): string => {
if (!startDate) return 'Unknown';
const endDate = dayjs(startDate).add(years, 'year');
const now = dayjs();
if (now.isAfter(endDate)) {
return 'Expired';
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
return 'Expiring Soon';
} else {
return 'Active';
}
}
const getPlanStatusBadge = (startDate: string, years: number): string => {
if (!startDate) return 'badge-ghost';
const endDate = dayjs(startDate).add(years, 'year');
const now = dayjs();
if (now.isAfter(endDate)) {
return 'badge-error';
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
return 'badge-warning';
} else {
return 'badge-success';
}
}
const getPlanName = (planType: number): string => {
const planNames: { [key: number]: string } = {
1: 'Standard Plan',
2: 'Premium Plan'
};
return planNames[planType] || 'No Plan';
}
const getStatusText = (startDate: string, years: number): string => {
if (!startDate) return 'Unknown';
const endDate = dayjs(startDate).add(years, 'year');
const now = dayjs();
if (now.isAfter(endDate)) {
return 'Expired';
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
return 'Expiring Soon';
} else {
return 'Active';
}
}
const getStatusBadge = (startDate: string, years: number): string => {
if (!startDate) return 'badge-ghost';
const endDate = dayjs(startDate).add(years, 'year');
const now = dayjs();
if (now.isAfter(endDate)) {
return 'badge-error';
} else if (now.isAfter(endDate.subtract(30, 'day'))) {
return 'badge-warning';
} else {
return 'badge-success';
}
}
const loadServicePlan = async (customerId: number) => {
try {
const response = await serviceService.plans.getForCustomer(customerId);
const plan = response.data?.plan || response.data;
if (plan && plan.contract_plan !== undefined) {
servicePlan.value = plan;
}
} catch (error) {
console.log('No existing service plan found');
}
}
const checkAuthorizeAccount = async () => {
if (!customer.value.id) return;
isLoadingAuthorize.value = true;
try {
const response = await authService.authorize.checkAccount(customer.value.id);
authorizeCheck.value = response.data;
// Check if the API returned an error in the response body
if (authorizeCheck.value.missing_components && authorizeCheck.value.missing_components.includes('api_error')) {
console.log("API error detected in response, calling cleanup for customer:", customer.value.id);
cleanupAuthorizeData();
return; // Don't set loading to false yet, let cleanup handle it
}
} catch (error) {
console.error("Failed to check authorize account:", error);
notify({ title: "Error", text: "Could not check payment account status.", type: "error" });
// Set default error state
authorizeCheck.value = {
profile_exists: false,
has_payment_methods: false,
missing_components: ['api_error'],
valid_for_charging: false
};
// Automatically cleanup the local Authorize.Net data on API error
console.log("Calling cleanupAuthorizedData for customer:", customer.value.id);
cleanupAuthorizeData();
} finally {
isLoadingAuthorize.value = false;
}
}
const createAuthorizeAccount = async () => {
// Show the creating account modal
isCreatingAccount.value = true;
isCreateAccountModalVisible.value = true;
try {
const response = await authService.authorize.createAccount(customer.value.id, {});
if (response.data.success) {
// Update local state
customer.value.auth_net_profile_id = response.data.profile_id;
authorizeCheck.value.valid_for_charging = true;
authorizeCheck.value.profile_exists = true;
authorizeCheck.value.has_payment_methods = true;
authorizeCheck.value.missing_components = [];
createdProfileId.value = response.data.profile_id;
// Refresh credit cards to get updated payment profile IDs
await getCreditCards(customer.value.id);
// Switch modal to success view and close after delay
setTimeout(() => {
isCreatingAccount.value = false;
setTimeout(() => {
isCreateAccountModalVisible.value = false;
createdProfileId.value = '';
notify({
title: "Success",
text: "Authorize.net account created successfully!",
type: "success"
});
}, 3000); // Show success message for 3 seconds
}, 1000); // Brief delay to show success animation
} else {
// Hide modal on error
isCreateAccountModalVisible.value = false;
// Check for E00039 duplicate error
const errorMessage = response.data.message || response.data.error_detail || "Failed to create Authorize.net account";
if (response.data.is_duplicate || errorMessage.includes("E00039")) {
// Show duplicate account popup
setTimeout(() => {
showDuplicateErrorModal();
}, 300);
return;
} else {
// Normal error notification
notify({
title: "Error",
text: errorMessage,
type: "error"
});
}
}
} catch (error: any) {
console.error("Failed to create account:", error);
isCreateAccountModalVisible.value = false;
isCreatingAccount.value = false;
// Check for E00039 duplicate error
const errorMessage = error.response?.data?.error_detail ||
error.response?.data?.detail ||
error.response?.data?.message ||
error.message || "Failed to create Authorize.net account";
if (error.response?.data?.is_duplicate || errorMessage.includes("E00039")) {
// Show duplicate account popup
setTimeout(() => {
showDuplicateErrorModal();
}, 300);
return;
}
// Normal error notification
notify({
title: "Error",
text: errorMessage,
type: "error"
});
}
}
// Duplicate Account Error Modal Methods
const showDuplicateErrorModal = () => {
isDuplicateErrorModalVisible.value = true;
}
const hideDuplicateErrorModal = () => {
isDuplicateErrorModalVisible.value = false;
}
const addCreditCard = () => {
// Redirect to add card page
router.push({ name: 'cardadd', params: { id: customer.value.id } });
}
const showDeleteAccountModal = () => {
isDeleteAccountModalVisible.value = true;
}
const deleteAccount = async () => {
isDeleteAccountModalVisible.value = false;
try {
const response = await authService.authorize.deleteAccount(customer.value.id);
if (response.data.success) {
// Update local state
customer.value.auth_net_profile_id = null;
authorizeCheck.value.valid_for_charging = false;
authorizeCheck.value.profile_exists = false;
authorizeCheck.value.has_payment_methods = false;
// Refresh credit cards list (IDs should now be null)
getCreditCards(customer.value.id);
notify({
title: "Success",
text: "Authorize.net account deleted successfully",
type: "success"
});
} else {
notify({
title: "Warning",
text: response.data.message || "Account deletion completed with warnings",
type: "warning"
});
}
} catch (error: any) {
console.error("Failed to delete account:", error);
notify({
title: "Error",
text: "Failed to delete Authorize.net account",
type: "error"
});
}
}
const cleanupAuthorizeData = async () => {
try {
const response = await paymentService.cleanupAuthorization(customer.value.id);
if (response.data.ok) {
// Update local state to reflect cleanup
customer.value.auth_net_profile_id = null;
authorizeCheck.value.valid_for_charging = false;
authorizeCheck.value.profile_exists = false;
authorizeCheck.value.has_payment_methods = false;
// Refresh credit cards to reflect null payment profile IDs
getCreditCards(customer.value.id);
console.log("Successfully cleaned up Authorize.Net data:", response.data.message);
} else {
console.error("Failed to cleanup Authorize.Net data:", response.data.error);
}
} catch (error) {
console.error("Error during cleanup:", error);
}
}
const getAccountStatusMessage = (): string => {
if (!authorizeCheck.value || !authorizeCheck.value.missing_components) {
return 'Account setup incomplete';
}
const missing = authorizeCheck.value.missing_components;
if (missing.includes('customer_not_found')) {
return 'Customer not found in Authorize.net';
} else if (missing.includes('authorize_net_profile')) {
return 'No Authorize.net profile configured';
} else if (missing.includes('authorize_net_profile_invalid')) {
return 'Authorize.net profile is invalid';
} else if (missing.includes('payment_method')) {
return 'No payment methods configured';
} else if (missing.includes('api_error')) {
return 'Error checking account status';
} else {
return 'Account requires setup';
}
}
</script>
<style scoped>
.card-glass {
@apply bg-gradient-to-br from-neutral/90 to-neutral/70 backdrop-blur-sm rounded-xl shadow-lg border border-base-content/5;
}
</style>