diff --git a/thanasoft-front/src/components/Organism/CRM/ClientDetailPresentation.vue b/thanasoft-front/src/components/Organism/CRM/ClientDetailPresentation.vue index d93e354..524ee81 100644 --- a/thanasoft-front/src/components/Organism/CRM/ClientDetailPresentation.vue +++ b/thanasoft-front/src/components/Organism/CRM/ClientDetailPresentation.vue @@ -23,7 +23,8 @@ :initials="getInitials(client.name)" :client-name="client.name" :client-type="client.type_label || 'Client'" - :contacts-count="client.length" + :contacts-count="contacts.length" + :locations-count="locations.length" :is-active="client.is_active" :active-tab="activeTab" @edit-avatar="triggerFileInput" @@ -44,12 +45,17 @@ :active-tab="activeTab" :client="client" :contacts="contacts" + :locations="locations" :formatted-address="formatAddress(client)" :client-id="client.id" :contact-is-loading="contactLoading" + :location-is-loading="locationLoading" @change-tab="activeTab = $event" @updating-client="handleUpdateClient" @create-contact="handleAddContact" + @create-location="handleAddLocation" + @modify-location="handleModifyLocation" + @remove-location="handleRemoveLocation" /> @@ -71,6 +77,11 @@ const props = defineProps({ required: false, default: () => [], }, + locations: { + type: Array, + required: false, + default: () => [], + }, isLoading: { type: Boolean, default: false, @@ -91,6 +102,10 @@ const props = defineProps({ type: Boolean, default: false, }, + locationLoading: { + type: Boolean, + default: false, + }, }); const localAvatar = ref(props.clientAvatar); @@ -99,6 +114,9 @@ const emit = defineEmits([ "updateTheClient", "handleFileInput", "add-new-contact", + "add-new-location", + "modify-location", + "remove-location", ]); const handleAvatarUpload = (event) => { @@ -123,10 +141,21 @@ const inputFile = () => { }; const handleAddContact = (data) => { - // TODO: Implement add contact functionality emit("add-new-contact", data); }; +const handleAddLocation = (data) => { + emit("add-new-location", data); +}; + +const handleModifyLocation = (location) => { + emit("modify-location", location); +}; + +const handleRemoveLocation = (locationId) => { + emit("remove-location", locationId); +}; + const getInitials = (name) => { if (!name) return "?"; return name diff --git a/thanasoft-front/src/components/Organism/CRM/client/ClientDetailContent.vue b/thanasoft-front/src/components/Organism/CRM/client/ClientDetailContent.vue index 865e619..a80e6a5 100644 --- a/thanasoft-front/src/components/Organism/CRM/client/ClientDetailContent.vue +++ b/thanasoft-front/src/components/Organism/CRM/client/ClientDetailContent.vue @@ -31,6 +31,18 @@ + +
+ +
+
@@ -43,6 +55,7 @@ import ClientOverview from "@/components/molecules/client/ClientOverview.vue"; import ClientInfoTab from "@/components/molecules/client/ClientInfoTab.vue"; import ClientContactsTab from "@/components/molecules/client/ClientContactsTab.vue"; import ClientAddressTab from "@/components/molecules/client/ClientAddressTab.vue"; +import ClientLocationsTab from "@/components/molecules/client/ClientLocationsTab.vue"; import ClientNotesTab from "@/components/molecules/client/ClientNotesTab.vue"; import { defineProps, defineEmits } from "vue"; @@ -59,6 +72,10 @@ defineProps({ type: Array, default: () => [], }, + locations: { + type: Array, + default: () => [], + }, formattedAddress: { type: String, default: "Aucune adresse renseignée", @@ -71,9 +88,21 @@ defineProps({ type: Boolean, default: false, }, + locationIsLoading: { + type: Boolean, + default: false, + }, }); -const emit = defineEmits(["change-tab", "create-contact", "updatingClient"]); +const emit = defineEmits([ + "change-tab", + "create-contact", + "updatingClient", + "create-location", + "modify-location", + "remove-location", +]); + const updateClient = (updatedClient) => { emit("updatingClient", updatedClient); }; @@ -81,4 +110,16 @@ const updateClient = (updatedClient) => { const handleCreateContact = (newContact) => { emit("create-contact", newContact); }; + +const handleCreateLocation = (newLocation) => { + emit("create-location", newLocation); +}; + +const handleModifyLocation = (location) => { + emit("modify-location", location); +}; + +const handleRemoveLocation = (locationId) => { + emit("remove-location", locationId); +}; diff --git a/thanasoft-front/src/components/Organism/CRM/client/ClientDetailSidebar.vue b/thanasoft-front/src/components/Organism/CRM/client/ClientDetailSidebar.vue index 1574698..241bfc8 100644 --- a/thanasoft-front/src/components/Organism/CRM/client/ClientDetailSidebar.vue +++ b/thanasoft-front/src/components/Organism/CRM/client/ClientDetailSidebar.vue @@ -18,6 +18,7 @@
@@ -49,6 +50,10 @@ defineProps({ type: Number, default: 0, }, + locationsCount: { + type: Number, + default: 0, + }, isActive: { type: Boolean, default: true, diff --git a/thanasoft-front/src/components/molecules/client/ClientLocationsTab.vue b/thanasoft-front/src/components/molecules/client/ClientLocationsTab.vue new file mode 100644 index 0000000..1f38e81 --- /dev/null +++ b/thanasoft-front/src/components/molecules/client/ClientLocationsTab.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/thanasoft-front/src/components/molecules/client/ClientTabNavigation.vue b/thanasoft-front/src/components/molecules/client/ClientTabNavigation.vue index d318467..ec9cd3e 100644 --- a/thanasoft-front/src/components/molecules/client/ClientTabNavigation.vue +++ b/thanasoft-front/src/components/molecules/client/ClientTabNavigation.vue @@ -26,6 +26,13 @@ :is-active="activeTab === 'address'" @click="$emit('change-tab', 'address')" /> + + + + + + + diff --git a/thanasoft-front/src/services/client_location.ts b/thanasoft-front/src/services/client_location.ts new file mode 100644 index 0000000..c5aa3c1 --- /dev/null +++ b/thanasoft-front/src/services/client_location.ts @@ -0,0 +1,272 @@ +import { request } from "./http"; + +export interface ClientLocationAddress { + address_line1: string | null; + address_line2: string | null; + postal_code: string | null; + city: string | null; + country_code: string; + full_address?: string; +} + +export interface ClientLocation { + id: number; + client_id: number; + name: string | null; + address_line1: string | null; + address_line2: string | null; + postal_code: string | null; + city: string | null; + country_code: string; + gps_lat: number | null; + gps_lng: number | null; + code_portail: string | null; + code_alarm: string | null; + code_funeraire: string | null; + is_default: boolean; + full_address?: string; + gps_coordinates?: { + lat: number; + lng: number; + } | null; + created_at: string; + updated_at: string; +} + +export interface ClientLocationListResponse { + data: ClientLocation[]; + meta?: { + current_page: number; + last_page: number; + per_page: number; + total: number; + }; +} + +export interface ClientLocationResponse { + data: ClientLocation; +} + +export interface CreateClientLocationPayload { + client_id: number; + name: string; + address_line1?: string | null; + address_line2?: string | null; + postal_code?: string | null; + city?: string | null; + country_code?: string; + gps_lat?: number | null; + gps_lng?: number | null; + code_portail?: string | null; + code_alarm?: string | null; + code_funeraire?: string | null; + is_default?: boolean; +} + +export interface UpdateClientLocationPayload + extends Partial { + id: number; +} + +export const ClientLocationService = { + /** + * Get all client locations with pagination + */ + async getAllClientLocations(params?: { + page?: number; + per_page?: number; + client_id?: number; + is_default?: boolean; + search?: string; + }): Promise { + const response = await request({ + url: "/api/client-locations", + method: "get", + params, + }); + + return response; + }, + + /** + * Get a specific client location by ID + */ + async getClientLocation(id: number): Promise { + const response = await request({ + url: `/api/client-locations/${id}`, + method: "get", + }); + + return response; + }, + + /** + * Create a new client location + */ + async createClientLocation( + payload: CreateClientLocationPayload + ): Promise { + const formattedPayload = this.transformClientLocationPayload(payload); + + const response = await request({ + url: "/api/client-locations", + method: "post", + data: formattedPayload, + }); + + return response; + }, + + /** + * Update an existing client location + */ + async updateClientLocation( + payload: UpdateClientLocationPayload + ): Promise { + const { id, ...updateData } = payload; + const formattedPayload = this.transformClientLocationPayload(updateData); + + const response = await request({ + url: `/api/client-locations/${id}`, + method: "put", + data: formattedPayload, + }); + + return response; + }, + + /** + * Delete a client location + */ + async deleteClientLocation( + id: number + ): Promise<{ success: boolean; message: string }> { + const response = await request<{ success: boolean; message: string }>({ + url: `/api/client-locations/${id}`, + method: "delete", + }); + + return response; + }, + + /** + * Set a location as default for a client + */ + async setAsDefaultLocation(id: number): Promise { + const response = await request({ + url: `/api/client-locations/${id}/set-default`, + method: "patch", + }); + + return response; + }, + + /** + * Get the default location for a client + */ + async getDefaultClientLocation( + clientId: number + ): Promise { + try { + const response = await request({ + url: "/api/client-locations", + method: "get", + params: { + client_id: clientId, + is_default: true, + per_page: 1, + }, + }); + + return response.data.length > 0 ? { data: response.data[0] } : null; + } catch (error) { + console.error("Error fetching default location:", error); + return null; + } + }, + + /** + * Search client locations by name, address, or city + */ + async getClientLocations(query: string): Promise { + const response = await request<{ + data: ClientLocation[]; + }>({ + url: `/api/clients/${query}/locations`, + method: "get", + }); + + return response.data; + }, + + /** + * Transform client location payload to match Laravel form request structure + */ + transformClientLocationPayload( + payload: Partial + ): any { + const transformed: any = { ...payload }; + + // Ensure boolean values are properly formatted + if (typeof transformed.is_default === "boolean") { + transformed.is_default = transformed.is_default ? 1 : 0; + } + + // Set default country code if not provided + if (!transformed.country_code) { + transformed.country_code = "FR"; + } + + // Remove undefined values to avoid sending them + Object.keys(transformed).forEach((key) => { + if (transformed[key] === undefined) { + delete transformed[key]; + } + }); + + return transformed; + }, + + /** + * Bulk update client locations + */ + async bulkUpdateLocations( + updates: Array<{ + id: number; + name?: string; + is_default?: boolean; + }> + ): Promise<{ success: boolean; message: string }> { + const response = await request<{ success: boolean; message: string }>({ + url: "/api/client-locations/bulk-update", + method: "patch", + data: { updates }, + }); + + return response; + }, + + /** + * Validate address using GPS coordinates + */ + async validateAddress( + locationId: number + ): Promise<{ + valid: boolean; + coordinates: { lat: number; lng: number } | null; + message: string; + }> { + const response = await request<{ + valid: boolean; + coordinates: { lat: number; lng: number } | null; + message: string; + }>({ + url: `/api/client-locations/${locationId}/validate-address`, + method: "get", + }); + + return response; + }, +}; + +export default ClientLocationService; diff --git a/thanasoft-front/src/stores/clientLocation.ts b/thanasoft-front/src/stores/clientLocation.ts new file mode 100644 index 0000000..6bad766 --- /dev/null +++ b/thanasoft-front/src/stores/clientLocation.ts @@ -0,0 +1,456 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import ClientLocationService from "@/services/client_location"; + +import type { + ClientLocation, + CreateClientLocationPayload, + UpdateClientLocationPayload, + ClientLocationListResponse, +} from "@/services/client_location"; + +export const useClientLocationStore = defineStore("clientLocation", () => { + // State + const clientLocations = ref([]); + const currentClientLocation = ref(null); + const loading = ref(false); + const error = ref(null); + const searchResults = ref([]); + + // Pagination state + const pagination = ref({ + current_page: 1, + last_page: 1, + per_page: 10, + total: 0, + }); + + // Getters + const allClientLocations = computed(() => clientLocations.value); + const defaultLocations = computed(() => + clientLocations.value.filter((location) => location.is_default) + ); + const nonDefaultLocations = computed(() => + clientLocations.value.filter((location) => !location.is_default) + ); + const isLoading = computed(() => loading.value); + const hasError = computed(() => error.value !== null); + const getError = computed(() => error.value); + const getLocationById = computed(() => (id: number) => + clientLocations.value.find((location) => location.id === id) + ); + + const getDefaultLocationByClientId = computed(() => (clientId: number) => + clientLocations.value.find( + (location) => location.client_id === clientId && location.is_default + ) + ); + const getPagination = computed(() => pagination.value); + + // Actions + const setLoading = (isLoading: boolean) => { + loading.value = isLoading; + }; + + const setError = (err: string | null) => { + error.value = err; + }; + + const clearError = () => { + error.value = null; + }; + + const setClientLocations = (newLocations: ClientLocation[]) => { + clientLocations.value = newLocations; + }; + + const setCurrentClientLocation = (location: ClientLocation | null) => { + currentClientLocation.value = location; + }; + + const setSearchResults = (results: ClientLocation[]) => { + searchResults.value = results; + }; + + const setPagination = (meta: any) => { + if (meta) { + pagination.value = { + current_page: meta.current_page || 1, + last_page: meta.last_page || 1, + per_page: meta.per_page || 10, + total: meta.total || 0, + }; + } + }; + + /** + * Fetch all client locations with optional pagination and filters + */ + const fetchClientLocations = async (params?: { + page?: number; + per_page?: number; + client_id?: number; + is_default?: boolean; + search?: string; + }) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.getAllClientLocations( + params + ); + setClientLocations(response.data); + if (response.meta) { + setPagination(response.meta); + } + return response; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to fetch client locations"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Fetch locations for a specific client + */ + const fetchClientLocationsByClient = async ( + clientId: number, + params?: { + page?: number; + per_page?: number; + is_default?: boolean; + } + ) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.getClientLocations( + clientId, + params + ); + setClientLocations(response.data); + if (response.meta) { + setPagination(response.meta); + } + return response; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to fetch client locations"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Fetch a single client location by ID + */ + const fetchClientLocation = async (id: number) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.getClientLocation(id); + setCurrentClientLocation(response.data); + return response.data; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to fetch client location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Create a new client location + */ + const createClientLocation = async (payload: CreateClientLocationPayload) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.createClientLocation( + payload + ); + const newLocation = response.data; + + // Add the new location to the list + clientLocations.value.push(newLocation); + setCurrentClientLocation(newLocation); + + // If this location is set as default, update other locations + if (newLocation.is_default) { + clientLocations.value = clientLocations.value.map((location) => + location.client_id === newLocation.client_id && + location.id !== newLocation.id + ? { ...location, is_default: false } + : location + ); + } + + return newLocation; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to create client location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Update an existing client location + */ + const updateClientLocation = async (payload: UpdateClientLocationPayload) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.updateClientLocation( + payload + ); + const updatedLocation = response.data; + + // Update in the locations list + const index = clientLocations.value.findIndex( + (location) => location.id === updatedLocation.id + ); + if (index !== -1) { + clientLocations.value[index] = updatedLocation; + } + + // Update current location if it's the one being edited + if ( + currentClientLocation.value && + currentClientLocation.value.id === updatedLocation.id + ) { + setCurrentClientLocation(updatedLocation); + } + + // If this location is set as default, update other locations + if (updatedLocation.is_default) { + clientLocations.value = clientLocations.value.map((location) => + location.client_id === updatedLocation.client_id && + location.id !== updatedLocation.id + ? { ...location, is_default: false } + : location + ); + } + + return updatedLocation; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to update client location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Delete a client location + */ + const deleteClientLocation = async (id: number) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.deleteClientLocation(id); + + // Remove from the locations list + clientLocations.value = clientLocations.value.filter( + (location) => location.id !== id + ); + + // Clear current location if it's the one being deleted + if ( + currentClientLocation.value && + currentClientLocation.value.id === id + ) { + setCurrentClientLocation(null); + } + + return response; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to delete client location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Set a location as default for a client + */ + const setAsDefaultLocation = async (id: number) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.setAsDefaultLocation(id); + const updatedLocation = response.data; + + // Update all locations for this client + clientLocations.value = clientLocations.value.map((location) => + location.client_id === updatedLocation.client_id + ? { + ...location, + is_default: location.id === updatedLocation.id, + } + : location + ); + + // Update current location if it's the one being set as default + if ( + currentClientLocation.value && + currentClientLocation.value.id === updatedLocation.id + ) { + setCurrentClientLocation(updatedLocation); + } + + return updatedLocation; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to set default location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Get the default location for a client + */ + const fetchDefaultClientLocation = async (clientId: number) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.getDefaultClientLocation( + clientId + ); + return response ? response.data : null; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to fetch default location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + const getClientLocations = async (clientId: number) => { + setLoading(true); + setError(null); + + try { + const response = await ClientLocationService.getClientLocations(clientId); + return response; + } catch (err: any) { + const errorMessage = + err.response?.data?.message || + err.message || + "Failed to fetch default location"; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }; + + /** + * Clear current client location + */ + const clearCurrentClientLocation = () => { + setCurrentClientLocation(null); + }; + + /** + * Clear search results + */ + const clearSearchResults = () => { + searchResults.value = []; + }; + + /** + * Clear all state + */ + const clearStore = () => { + clientLocations.value = []; + currentClientLocation.value = null; + error.value = null; + searchResults.value = []; + pagination.value = { + current_page: 1, + last_page: 1, + per_page: 10, + total: 0, + }; + }; + + return { + // State + clientLocations, + currentClientLocation, + loading, + error, + searchResults, + + // Getters + allClientLocations, + defaultLocations, + nonDefaultLocations, + isLoading, + hasError, + getError, + getLocationById, + getDefaultLocationByClientId, + getPagination, + + // Actions + fetchClientLocations, + fetchClientLocationsByClient, + fetchClientLocation, + createClientLocation, + updateClientLocation, + deleteClientLocation, + setAsDefaultLocation, + fetchDefaultClientLocation, + clearCurrentClientLocation, + clearSearchResults, + clearStore, + clearError, + getClientLocations, + }; +}); diff --git a/thanasoft-front/src/views/pages/CRM/ClientDetails.vue b/thanasoft-front/src/views/pages/CRM/ClientDetails.vue index 55919ff..4393e9d 100644 --- a/thanasoft-front/src/views/pages/CRM/ClientDetails.vue +++ b/thanasoft-front/src/views/pages/CRM/ClientDetails.vue @@ -3,13 +3,18 @@ v-if="clientStore.currentClient" :client="clientStore.currentClient" :contacts="contacts_client" + :locations="locations_client" :is-loading="clientStore.isLoading" :client-avatar="clientAvatar" :active-tab="activeTab" :file-input="fileInput" :contact-loading="contactStore.isLoading" + :location-loading="clientLocationStore.isLoading" @update-the-client="updateClient" @add-new-contact="createNewContact" + @add-new-location="createNewLocation" + @modify-location="modifyLocation" + @remove-location="removeLocation" /> @@ -18,14 +23,18 @@ import { ref, onMounted } from "vue"; import { useRoute } from "vue-router"; import { useClientStore } from "@/stores/clientStore"; import { useContactStore } from "@/stores/contactStore"; +import { useClientLocationStore } from "@/stores/clientLocation"; import ClientDetailPresentation from "@/components/Organism/CRM/ClientDetailPresentation.vue"; + const route = useRoute(); const clientStore = useClientStore(); const contactStore = useContactStore(); +const clientLocationStore = useClientLocationStore(); // Ensure client_id is a number const client_id = Number(route.params.id); const contacts_client = ref([]); +const locations_client = ref([]); const activeTab = ref("overview"); const clientAvatar = ref(null); const fileInput = ref(null); @@ -34,6 +43,10 @@ onMounted(async () => { if (client_id) { await clientStore.fetchClient(client_id); contacts_client.value = await contactStore.getClientListContact(client_id); + const locationsResponse = await clientLocationStore.getClientLocations( + client_id + ); + locations_client.value = locationsResponse || []; } }); @@ -61,4 +74,37 @@ const createNewContact = async (data) => { console.error("Error creating contact:", error); } }; + +const createNewLocation = async (data) => { + try { + await clientLocationStore.createClientLocation(data); + // Refresh locations list after creation + const response = await clientLocationStore.getClientLocations(client_id); + locations_client.value = response || []; + } catch (error) { + console.error("Error creating location:", error); + } +}; + +const modifyLocation = async (location) => { + try { + await clientLocationStore.updateClientLocation(location); + // Refresh locations list after modification + const response = await clientLocationStore.getClientLocations(client_id); + locations_client.value = response || []; + } catch (error) { + console.error("Error modifying location:", error); + } +}; + +const removeLocation = async (locationId) => { + try { + await clientLocationStore.deleteClientLocation(locationId); + // Refresh locations list after deletion + const response = await clientLocationStore.getClientLocations(client_id); + locations_client.value = response || []; + } catch (error) { + console.error("Error removing location:", error); + } +};