diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b0eba41..10e07c9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,10 +14,11 @@ user types we have ## Project Context BottleCRM is a modern CRM application built with: -- **Framework**: SvelteKit 2.21.x, Svelte 5.x, Prisma +- **Framework**: SvelteKit 2.21.x, Svelte 5.1, Prisma - **Styling**: tailwind 4.1.x css - **Database**: postgresql - **Icons**: lucide icons +- **Form Validation**: zod ## Important Notes - We need to ensure access control is strictly enforced based on user roles. diff --git a/package.json b/package.json index 293635b..35cdf35 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@prisma/client": "6.5.0", "axios": "^1.9.0", "date-fns": "^4.1.0", + "libphonenumber-js": "^1.12.9", "marked": "^15.0.12", "svelte-highlight": "^7.8.3", "svelte-meta-tags": "^4.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 638e771..1278876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + libphonenumber-js: + specifier: ^1.12.9 + version: 1.12.9 marked: specifier: ^15.0.12 version: 15.0.12 @@ -1083,6 +1086,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.9: + resolution: {integrity: sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -2443,6 +2449,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.9: {} + lightningcss-darwin-arm64@1.30.1: optional: true diff --git a/prisma/migrations/20250618024526_/migration.sql b/prisma/migrations/20250618024526_/migration.sql new file mode 100644 index 0000000..0c1386e --- /dev/null +++ b/prisma/migrations/20250618024526_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `department` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "department"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 080afa2..d12c2f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,7 +22,6 @@ model User { updatedAt DateTime @updatedAt profilePhoto String? phone String? - department String? isActive Boolean @default(true) lastLogin DateTime? accounts Account[] diff --git a/src/lib/utils/phone.js b/src/lib/utils/phone.js new file mode 100644 index 0000000..97ebd1f --- /dev/null +++ b/src/lib/utils/phone.js @@ -0,0 +1,74 @@ +import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js'; + +/** + * Validates a phone number and returns validation result + * @param {string} phoneNumber - The phone number to validate + * @param {string} defaultCountry - Default country code (e.g., 'US') + * @returns {{ isValid: boolean, formatted?: string, error?: string }} + */ +export function validatePhoneNumber(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber || phoneNumber.trim() === '') { + return { isValid: true }; // Allow empty phone numbers + } + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const isValid = isValidPhoneNumber(phoneNumber, { defaultCountry }); + + if (!isValid) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } + + // Parse and format the phone number + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return { + isValid: true, + formatted: parsed.formatInternational() + }; + } catch (error) { + return { + isValid: false, + error: 'Please enter a valid phone number' + }; + } +} + +/** + * Formats a phone number for display + * @param {string} phoneNumber - The phone number to format + * @param {string} defaultCountry - Default country code + * @returns {string} Formatted phone number or original if invalid + */ +export function formatPhoneNumber(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber) return ''; + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.formatInternational(); + } catch { + return phoneNumber; // Return original if parsing fails + } +} + +/** + * Formats a phone number for storage (E.164 format) + * @param {string} phoneNumber - The phone number to format + * @param {string} defaultCountry - Default country code + * @returns {string} E.164 formatted phone number or original if invalid + */ +export function formatPhoneForStorage(phoneNumber, defaultCountry = 'US') { + if (!phoneNumber) return ''; + + try { + // @ts-ignore - defaultCountry is a valid CountryCode + const parsed = parsePhoneNumber(phoneNumber, { defaultCountry }); + return parsed.format('E.164'); + } catch { + return phoneNumber; // Return original if parsing fails + } +} diff --git a/src/routes/(app)/app/accounts/new/+page.server.js b/src/routes/(app)/app/accounts/new/+page.server.js index 99f69c7..7f33240 100644 --- a/src/routes/(app)/app/accounts/new/+page.server.js +++ b/src/routes/(app)/app/accounts/new/+page.server.js @@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private'; import { redirect } from '@sveltejs/kit'; import prisma from '$lib/prisma'; import { fail } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; import { industries, accountTypes, @@ -44,13 +45,24 @@ export const actions = { return fail(400, { error: 'Account name is required' }); } + // Validate phone number if provided + let formattedPhone = null; + const phone = formData.get('phone')?.toString(); + if (phone && phone.trim().length > 0) { + const phoneValidation = validatePhoneNumber(phone.trim()); + if (!phoneValidation.isValid) { + return fail(400, { error: phoneValidation.error || 'Please enter a valid phone number' }); + } + formattedPhone = formatPhoneForStorage(phone.trim()); + } + // Extract all form fields const accountData = { name, type: formData.get('type')?.toString() || null, industry: formData.get('industry')?.toString() || null, website: formData.get('website')?.toString() || null, - phone: formData.get('phone')?.toString() || null, + phone: formattedPhone, street: formData.get('street')?.toString() || null, city: formData.get('city')?.toString() || null, state: formData.get('state')?.toString() || null, diff --git a/src/routes/(app)/app/accounts/new/+page.svelte b/src/routes/(app)/app/accounts/new/+page.svelte index 130c215..d624bec 100644 --- a/src/routes/(app)/app/accounts/new/+page.svelte +++ b/src/routes/(app)/app/accounts/new/+page.svelte @@ -164,7 +164,7 @@

{toastMessage}

@@ -241,7 +241,7 @@ name="name" type="text" bind:value={formData.name} - on:input={handleChange} + oninput={handleChange} placeholder="Enter account name" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.name ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> @@ -259,7 +259,7 @@ id="type" name="type" bind:value={formData.type} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.accountTypes as [value, label]} @@ -276,7 +276,7 @@ id="industry" name="industry" bind:value={formData.industry} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.industries as [value, label]} @@ -293,7 +293,7 @@ id="rating" name="rating" bind:value={formData.rating} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.ratings as [value, label]} @@ -310,7 +310,7 @@ id="accountOwnership" name="accountOwnership" bind:value={formData.accountOwnership} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.accountOwnership as [value, label]} @@ -342,7 +342,7 @@ name="website" type="url" bind:value={formData.website} - on:input={handleChange} + oninput={handleChange} placeholder="https://company.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.website ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.website} @@ -361,7 +361,7 @@ name="phone" type="tel" bind:value={formData.phone} - on:input={handleChange} + oninput={handleChange} placeholder="+1 (555) 123-4567" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.phone ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.phone} @@ -392,7 +392,7 @@ name="street" type="text" bind:value={formData.street} - on:input={handleChange} + oninput={handleChange} placeholder="Street address" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -404,7 +404,7 @@ name="city" type="text" bind:value={formData.city} - on:input={handleChange} + oninput={handleChange} placeholder="City" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -416,7 +416,7 @@ name="state" type="text" bind:value={formData.state} - on:input={handleChange} + oninput={handleChange} placeholder="State" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -428,7 +428,7 @@ name="postalCode" type="text" bind:value={formData.postalCode} - on:input={handleChange} + oninput={handleChange} placeholder="Postal code" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -439,7 +439,7 @@ id="country" name="country" bind:value={formData.country} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.countries as [value, label]} @@ -472,7 +472,7 @@ type="number" min="0" bind:value={formData.numberOfEmployees} - on:input={handleChange} + oninput={handleChange} placeholder="100" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.numberOfEmployees ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.numberOfEmployees} @@ -493,7 +493,7 @@ min="0" step="0.01" bind:value={formData.annualRevenue} - on:input={handleChange} + oninput={handleChange} placeholder="1000000" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.annualRevenue ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.annualRevenue} @@ -512,7 +512,7 @@ name="tickerSymbol" type="text" bind:value={formData.tickerSymbol} - on:input={handleChange} + oninput={handleChange} placeholder="AAPL" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -527,7 +527,7 @@ name="sicCode" type="text" bind:value={formData.sicCode} - on:input={handleChange} + oninput={handleChange} placeholder="7372" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -550,7 +550,7 @@ id="description" name="description" bind:value={formData.description} - on:input={handleChange} + oninput={handleChange} placeholder="Additional notes about this account..." rows="4" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-vertical"> @@ -564,7 +564,7 @@
- Edit diff --git a/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js b/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js index 83a01b6..dd0b513 100644 --- a/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js +++ b/src/routes/(app)/app/contacts/[contactId]/edit/+page.server.js @@ -1,5 +1,6 @@ import prisma from '$lib/prisma'; import { fail, redirect } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; export async function load({ params, locals }) { const org = locals.org; @@ -47,6 +48,16 @@ export const actions = { return fail(400, { message: 'First and last name are required.' }); } + // Validate phone number if provided + let formattedPhone = null; + if (phone && phone.length > 0) { + const phoneValidation = validatePhoneNumber(phone); + if (!phoneValidation.isValid) { + return fail(400, { message: phoneValidation.error || 'Please enter a valid phone number' }); + } + formattedPhone = formatPhoneForStorage(phone); + } + const contact = await prisma.contact.findUnique({ where: { id: params.contactId, organizationId: org.id } }); @@ -61,7 +72,7 @@ export const actions = { firstName, lastName, email, - phone, + phone: formattedPhone, title, department, street, diff --git a/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte b/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte index 25e5d38..80a3f45 100644 --- a/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte +++ b/src/routes/(app)/app/contacts/[contactId]/edit/+page.svelte @@ -4,6 +4,8 @@ import { onMount } from 'svelte'; import { invalidateAll } from '$app/navigation'; import { User, Mail, Phone, Building, MapPin, FileText, Star, Save, X, ArrowLeft } from '@lucide/svelte'; + import { validatePhoneNumber } from '$lib/utils/phone.js'; + export let data; let contact = data.contact; @@ -25,6 +27,22 @@ let description = contact.description || ''; let submitting = false; let errorMsg = ''; + let phoneError = ''; + + // Validate phone number on input + function validatePhone() { + if (!phone.trim()) { + phoneError = ''; + return; + } + + const validation = validatePhoneNumber(phone); + if (!validation.isValid) { + phoneError = validation.error || 'Invalid phone number'; + } else { + phoneError = ''; + } + } async function handleSubmit(e) { e.preventDefault(); @@ -185,8 +203,12 @@ class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400" bind:value={phone} placeholder="+1 (555) 123-4567" + oninput={validatePhone} />
+ {#if phoneError} +

{phoneError}

+ {/if} diff --git a/src/routes/(app)/app/contacts/new/+page.server.js b/src/routes/(app)/app/contacts/new/+page.server.js index 07bb5bb..e46de9b 100644 --- a/src/routes/(app)/app/contacts/new/+page.server.js +++ b/src/routes/(app)/app/contacts/new/+page.server.js @@ -1,5 +1,6 @@ import { redirect, fail } from '@sveltejs/kit'; import prisma from '$lib/prisma'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; /** @type {import('./$types').PageServerLoad} */ export async function load({ locals, url }) { @@ -94,6 +95,17 @@ export const actions = { errors.email = 'Please enter a valid email address'; } + // Validate phone number if provided + let formattedPhone = null; + if (phone && phone.length > 0) { + const phoneValidation = validatePhoneNumber(phone); + if (!phoneValidation.isValid) { + errors.phone = phoneValidation.error || 'Please enter a valid phone number'; + } else { + formattedPhone = formatPhoneForStorage(phone); + } + } + if (Object.keys(errors).length > 0) { return fail(400, { errors, @@ -199,7 +211,7 @@ export const actions = { firstName, lastName, email: email || null, - phone: phone || null, + phone: formattedPhone, title: title || null, department: department || null, street: street || null, diff --git a/src/routes/(app)/app/contacts/new/+page.svelte b/src/routes/(app)/app/contacts/new/+page.svelte index 85ed39a..dac84da 100644 --- a/src/routes/(app)/app/contacts/new/+page.svelte +++ b/src/routes/(app)/app/contacts/new/+page.svelte @@ -2,11 +2,13 @@ import { enhance } from '$app/forms'; import { page } from '$app/stores'; import { ArrowLeft, User, Mail, Phone, Building, MapPin, FileText, Save } from '@lucide/svelte'; + import { validatePhoneNumber } from '$lib/utils/phone.js'; /** @type {{ data: import('./$types').PageData, form: import('./$types').ActionData }} */ let { data, form } = $props(); let isSubmitting = $state(false); + let phoneError = $state(''); // Get accountId from URL parameters const accountId = $page.url.searchParams.get('accountId'); @@ -45,6 +47,21 @@ isSubmitting = false; }; } + + // Validate phone number on input + function validatePhone() { + if (!formValues.phone.trim()) { + phoneError = ''; + return; + } + + const validation = validatePhoneNumber(formValues.phone); + if (!validation.isValid) { + phoneError = validation.error || 'Invalid phone number'; + } else { + phoneError = ''; + } + } @@ -222,9 +239,16 @@ id="phone" name="phone" bind:value={formValues.phone} - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + oninput={validatePhone} + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {errors.phone ? 'border-red-300 dark:border-red-600' : ''}" placeholder="+1 (555) 123-4567" /> + {#if errors.phone} +

{errors.phone}

+ {/if} + {#if phoneError} +

{phoneError}

+ {/if} diff --git a/src/routes/(app)/app/leads/[lead_id]/+page.server.js b/src/routes/(app)/app/leads/[lead_id]/+page.server.js index 5dfa404..795a4d5 100644 --- a/src/routes/(app)/app/leads/[lead_id]/+page.server.js +++ b/src/routes/(app)/app/leads/[lead_id]/+page.server.js @@ -49,6 +49,8 @@ export const actions = { const org = locals.org; try { + console.log('Starting lead conversion for lead:', lead_id); + const lead = await prisma.lead.findUnique({ where: { id: lead_id, organizationId: org.id }, include: { @@ -65,6 +67,7 @@ export const actions = { return { status: 'success', message: 'Lead already converted' }; } + console.log('Creating contact...'); const contact = await prisma.contact.create({ data: { firstName: lead.firstName, @@ -77,10 +80,12 @@ export const actions = { organization: { connect: { id: lead.organizationId } } } }); + console.log('Contact created with ID:', contact.id); let accountId = null; let account = null; if (lead.company) { + console.log('Creating account for company:', lead.company); account = await prisma.account.create({ data: { name: lead.company, @@ -90,7 +95,9 @@ export const actions = { } }); accountId = account.id; + console.log('Account created with ID:', accountId); + console.log('Creating account-contact relationship...'); await prisma.accountContactRelationship.create({ data: { account: { connect: { id: account.id } }, @@ -99,46 +106,48 @@ export const actions = { role: 'Primary Contact' } }); - } - - const opportunityData = { - name: `${lead.company || lead.firstName + ' ' + lead.lastName} Opportunity`, - stage: 'PROSPECTING', - amount: 0, - closeDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - contacts: { connect: { id: contact.id } }, - owner: { connect: { id: lead.ownerId } }, - organization: { connect: { id: lead.organizationId } } - }; - - if (!accountId) { - const placeholderAccount = await prisma.account.create({ + console.log('Account-contact relationship created'); + } else { + console.log('Creating placeholder account...'); + // Create a placeholder account if no company + account = await prisma.account.create({ data: { name: `${lead.firstName} ${lead.lastName} Account`, owner: { connect: { id: lead.ownerId } }, organization: { connect: { id: lead.organizationId } } } }); + accountId = account.id; + console.log('Placeholder account created with ID:', accountId); - accountId = placeholderAccount.id; - account = placeholderAccount; - + console.log('Creating account-contact relationship...'); await prisma.accountContactRelationship.create({ data: { - account: { connect: { id: placeholderAccount.id } }, + account: { connect: { id: account.id } }, contact: { connect: { id: contact.id } }, isPrimary: true, role: 'Primary Contact' } }); + console.log('Account-contact relationship created'); } - opportunityData.account = { connect: { id: accountId } }; - + console.log('Creating opportunity...'); const opportunity = await prisma.opportunity.create({ - data: opportunityData + data: { + name: `${lead.company || lead.firstName + ' ' + lead.lastName} Opportunity`, + stage: 'PROSPECTING', + amount: 0, + closeDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + contacts: { connect: { id: contact.id } }, + owner: { connect: { id: lead.ownerId } }, + organization: { connect: { id: lead.organizationId } }, + account: { connect: { id: accountId } } + } }); + console.log('Opportunity created with ID:', opportunity.id); + console.log('Updating lead status...'); await prisma.lead.update({ where: { id: lead_id }, data: { @@ -151,17 +160,35 @@ export const actions = { contact: { connect: { id: contact.id } } } }); + console.log('Lead status updated successfully'); + console.log('Lead conversion completed, account created:', accountId); + return { status: 'success', message: 'Lead successfully converted', + redirectTo: `/app/accounts/${accountId}`, contact, account, opportunity }; } catch (err) { - console.error('Error converting lead:', err.message); - return fail(500, { status: 'error', message: 'Failed to convert lead' }); + console.error('Error converting lead:', err); + + // Extract meaningful error message + let errorMessage = 'Failed to convert lead'; + if (err instanceof Error) { + errorMessage = err.message; + } else if (typeof err === 'string') { + errorMessage = err; + } else if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { + errorMessage = err.message; + } + + return fail(500, { + status: 'error', + message: `Error converting lead: ${errorMessage}` + }); } }, @@ -206,10 +233,11 @@ export const actions = { return { status: 'success', message: 'Comment added successfully', + commentAdded: true, comments: updatedLead?.comments || [] }; } catch (err) { - console.error('Error adding comment:', err.message); + console.error('Error adding comment:', err instanceof Error ? err.message : String(err)); if (err instanceof z.ZodError) { return fail(400, { status: 'error', message: err.errors[0].message }); } diff --git a/src/routes/(app)/app/leads/[lead_id]/+page.svelte b/src/routes/(app)/app/leads/[lead_id]/+page.svelte index a666d13..6869213 100644 --- a/src/routes/(app)/app/leads/[lead_id]/+page.svelte +++ b/src/routes/(app)/app/leads/[lead_id]/+page.svelte @@ -1,7 +1,7 @@ + +{#if showConfirmModal} +
+
+
+
+ +
+
+

Convert Lead

+

This action cannot be undone

+
+
+ +

+ Are you sure you want to convert {getFullName(lead)} into an account and contact? + This will create new records and mark the lead as converted. +

+ +
+ + +
+
+
+{/if} + {#if showToast}
@@ -296,21 +369,23 @@
{#if lead.status !== 'CONVERTED'} -
- + +
+ {/if}
+ @@ -210,7 +232,7 @@

{toastMessage}

@@ -287,7 +309,7 @@ name="lead_title" type="text" bind:value={formData.lead_title} - on:input={handleChange} + oninput={handleChange} placeholder="Enter lead title" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.lead_title ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> @@ -307,7 +329,7 @@ name="company" type="text" bind:value={formData.company} - on:input={handleChange} + oninput={handleChange} placeholder="Company name" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" />
@@ -321,7 +343,7 @@ id="source" name="source" bind:value={formData.source} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.source as [value, label]} @@ -339,7 +361,7 @@ id="industry" name="industry" bind:value={formData.industry} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.industries as [value, label]} @@ -357,7 +379,7 @@ id="status" name="status" bind:value={formData.status} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> {#each data.data.status as [value, label]} @@ -374,7 +396,7 @@ id="rating" name="rating" bind:value={formData.rating} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> @@ -394,7 +416,7 @@ name="website" type="url" bind:value={formData.website} - on:input={handleChange} + oninput={handleChange} placeholder="https://company.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.website ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.website} @@ -413,7 +435,7 @@ id="opportunity_amount" name="opportunity_amount" bind:value={formData.opportunity_amount} - on:input={handleChange} + oninput={handleChange} placeholder="0" min="0" step="0.01" @@ -433,7 +455,7 @@ min="0" max="100" bind:value={formData.probability} - on:input={handleChange} + oninput={handleChange} placeholder="50" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.probability ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.probability} @@ -450,7 +472,7 @@ id="budget_range" name="budget_range" bind:value={formData.budget_range} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> @@ -471,7 +493,7 @@ id="decision_timeframe" name="decision_timeframe" bind:value={formData.decision_timeframe} - on:change={handleChange} + onchange={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors"> @@ -504,7 +526,7 @@ name="first_name" type="text" bind:value={formData.first_name} - on:input={handleChange} + oninput={handleChange} placeholder="First name" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.first_name ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> @@ -523,7 +545,7 @@ name="last_name" type="text" bind:value={formData.last_name} - on:input={handleChange} + oninput={handleChange} placeholder="Last name" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.last_name ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> @@ -542,7 +564,7 @@ name="title" type="text" bind:value={formData.title} - on:input={handleChange} + oninput={handleChange} placeholder="Job title" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -558,12 +580,16 @@ name="phone" type="tel" bind:value={formData.phone} - on:input={handleChange} + oninput={handleChange} + onblur={validatePhone} placeholder="+1 (555) 123-4567" - class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.phone ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> + class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.phone || phoneError ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.phone}

{errors.phone}

{/if} + {#if phoneError} +

{phoneError}

+ {/if} @@ -577,7 +603,7 @@ type="email" name="email" bind:value={formData.email} - on:input={handleChange} + oninput={handleChange} placeholder="email@company.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.email ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> @@ -596,7 +622,7 @@ name="linkedin_url" type="url" bind:value={formData.linkedin_url} - on:input={handleChange} + oninput={handleChange} placeholder="https://linkedin.com/in/username" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors {errors.linkedin_url ? 'border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400' : ''}" /> {#if errors.linkedin_url} @@ -627,7 +653,7 @@ name="address_line" type="text" bind:value={formData.address_line} - on:input={handleChange} + oninput={handleChange} placeholder="Street address" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -639,7 +665,7 @@ name="city" type="text" bind:value={formData.city} - on:input={handleChange} + oninput={handleChange} placeholder="City" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -651,7 +677,7 @@ name="state" type="text" bind:value={formData.state} - on:input={handleChange} + oninput={handleChange} placeholder="State" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -663,7 +689,7 @@ name="postcode" type="text" bind:value={formData.postcode} - on:input={handleChange} + oninput={handleChange} placeholder="Postal code" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -675,7 +701,7 @@ name="country" type="text" bind:value={formData.country} - on:input={handleChange} + oninput={handleChange} placeholder="Country" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -698,7 +724,7 @@ id="description" name="description" bind:value={formData.description} - on:input={handleChange} + oninput={handleChange} placeholder="Additional notes about this lead..." rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-vertical"> @@ -713,7 +739,7 @@ id="pain_points" name="pain_points" bind:value={formData.pain_points} - on:input={handleChange} + oninput={handleChange} placeholder="What challenges is the lead facing?" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors resize-vertical"> @@ -730,7 +756,7 @@ name="last_contacted" type="date" bind:value={formData.last_contacted} - on:input={handleChange} + oninput={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -743,7 +769,7 @@ name="next_follow_up" type="date" bind:value={formData.next_follow_up} - on:input={handleChange} + oninput={handleChange} class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 transition-colors" /> @@ -756,7 +782,7 @@
- + {#snippet statusIcon(config)} + {@const StatusIcon = config.icon} + + {/snippet} + {@render statusIcon(statusConfig)} {lead.status} diff --git a/src/routes/(app)/app/opportunities/+page.server.js b/src/routes/(app)/app/opportunities/+page.server.js index 93072d5..c329ff3 100644 --- a/src/routes/(app)/app/opportunities/+page.server.js +++ b/src/routes/(app)/app/opportunities/+page.server.js @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client'; +import { fail } from '@sveltejs/kit'; const prisma = new PrismaClient(); @@ -119,4 +120,44 @@ export async function load({ locals }) { } }; } +}; + +/** @type {import('./$types').Actions} */ +export const actions = { + delete: async ({ request, locals }) => { + try { + const formData = await request.formData(); + const opportunityId = formData.get('opportunityId')?.toString(); + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!opportunityId || !userId || !organizationId) { + return fail(400, { message: 'Missing required data' }); + } + + // Check if the opportunity exists and belongs to the user's organization + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: opportunityId, + organizationId: organizationId + } + }); + + if (!opportunity) { + return fail(404, { message: 'Opportunity not found' }); + } + + // Delete the opportunity + await prisma.opportunity.delete({ + where: { + id: opportunityId + } + }); + + return { success: true, message: 'Opportunity deleted successfully' }; + } catch (error) { + console.error('Error deleting opportunity:', error); + return fail(500, { message: 'Failed to delete opportunity' }); + } + } }; \ No newline at end of file diff --git a/src/routes/(app)/app/opportunities/+page.svelte b/src/routes/(app)/app/opportunities/+page.svelte index e03982d..b34ab6b 100644 --- a/src/routes/(app)/app/opportunities/+page.svelte +++ b/src/routes/(app)/app/opportunities/+page.svelte @@ -17,18 +17,25 @@ CheckCircle, XCircle, Clock, - Target + Target, + X, + AlertTriangle } from '@lucide/svelte'; import { goto } from '$app/navigation'; + import { enhance } from '$app/forms'; + import { page } from '$app/stores'; - /** @type {{ data: import('./$types').PageData }} */ - let { data } = $props(); + /** @type {{ data: import('./$types').PageData, form?: any }} */ + let { data, form } = $props(); let searchTerm = $state(''); let selectedStage = $state('all'); let sortField = $state('createdAt'); let sortDirection = $state('desc'); let showFilters = $state(false); + let showDeleteModal = $state(false); + let opportunityToDelete = $state(null); + let deleteLoading = $state(false); // Stage configurations const stageConfig = { @@ -101,6 +108,17 @@ sortDirection = 'asc'; } } + + function openDeleteModal(opportunity) { + opportunityToDelete = opportunity; + showDeleteModal = true; + } + + function closeDeleteModal() { + showDeleteModal = false; + opportunityToDelete = null; + deleteLoading = false; + } @@ -108,6 +126,25 @@
+ + {#if form?.success} +
+ +
+ {/if} + + {#if form?.message && !form?.success} +
+ +
+ {/if} +
+ + +{#if showDeleteModal && opportunityToDelete} + +{/if} \ No newline at end of file diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.js b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.js new file mode 100644 index 0000000..f3c8c3b --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.server.js @@ -0,0 +1,81 @@ +import { error } from '@sveltejs/kit'; +import { fail } from '@sveltejs/kit'; +import prisma from '$lib/prisma'; + +export async function load({ params, locals }) { + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!userId || !organizationId) { + throw error(401, 'Unauthorized'); + } + + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: organizationId + }, + include: { + account: { + select: { + id: true, + name: true + } + }, + owner: { + select: { + id: true, + name: true, + email: true + } + } + } + }); + + if (!opportunity) { + throw error(404, 'Opportunity not found'); + } + + return { + opportunity + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + default: async ({ params, locals }) => { + try { + const userId = locals.user?.id; + const organizationId = locals.org?.id; + + if (!userId || !organizationId) { + return fail(401, { message: 'Unauthorized' }); + } + + // Check if the opportunity exists and belongs to the user's organization + const opportunity = await prisma.opportunity.findFirst({ + where: { + id: params.opportunityId, + organizationId: organizationId + } + }); + + if (!opportunity) { + return fail(404, { message: 'Opportunity not found' }); + } + + // Delete the opportunity (this will cascade delete related records) + await prisma.opportunity.delete({ + where: { + id: params.opportunityId + } + }); + + // Return success response - let client handle redirect + return { success: true, message: 'Opportunity deleted successfully' }; + } catch (err) { + console.error('Error deleting opportunity:', err); + return fail(500, { message: 'Failed to delete opportunity' }); + } + } +}; diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte new file mode 100644 index 0000000..c7e51c4 --- /dev/null +++ b/src/routes/(app)/app/opportunities/[opportunityId]/delete/+page.svelte @@ -0,0 +1,112 @@ + + + + Delete Opportunity - BottleCRM + + +
+ +
+
+
+
+ + + +

Delete Opportunity

+
+
+
+
+ + +
+
+
+
+
+
+ +
+
+

+ Confirm Deletion +

+

+ This action cannot be undone. +

+
+
+ +
+

+ You are about to delete: +

+
+
Opportunity: {data.opportunity.name}
+
Account: {data.opportunity.account?.name || 'N/A'}
+
Amount: {data.opportunity.amount ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(data.opportunity.amount) : 'N/A'}
+
Stage: {data.opportunity.stage}
+
+
+ +
+

+ Warning: Deleting this opportunity will also remove all associated: +

+
    +
  • Tasks and activities
  • +
  • Events and meetings
  • +
  • Comments and notes
  • +
  • Quote associations
  • +
+
+ +
+ + Cancel + + +
{ + deleteLoading = true; + return ({ result }) => { + deleteLoading = false; + if (result.type === 'success') { + // Navigate to opportunities list on successful deletion + goto('/app/opportunities'); + } else if (result.type === 'failure') { + // Handle error case - you could show a toast notification here + console.error('Failed to delete opportunity:', result.data?.message); + alert(result.data?.message || 'Failed to delete opportunity'); + } + }; + }}> + +
+
+
+
+
+
+
diff --git a/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte b/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte index f1d2ec1..7f23eb1 100644 --- a/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte +++ b/src/routes/(app)/app/opportunities/[opportunityId]/edit/+page.svelte @@ -70,7 +70,7 @@
-
+

diff --git a/src/routes/(app)/app/profile/+page.server.js b/src/routes/(app)/app/profile/+page.server.js new file mode 100644 index 0000000..67a0bf2 --- /dev/null +++ b/src/routes/(app)/app/profile/+page.server.js @@ -0,0 +1,108 @@ +import prisma from '$lib/prisma'; +import { fail, redirect } from '@sveltejs/kit'; +import { validatePhoneNumber, formatPhoneForStorage } from '$lib/utils/phone.js'; + +/** @type {import('./$types').PageServerLoad} */ +export async function load({ locals }) { + if (!locals.user) { + throw redirect(307, '/login'); + } + + // Get user with their organization memberships + const user = await prisma.user.findUnique({ + where: { + id: locals.user.id + }, + include: { + organizations: { + include: { + organization: true + } + } + } + }); + + if (!user) { + throw redirect(307, '/login'); + } + + return { + user: { + id: user.id, + user_id: user.user_id, + email: user.email, + name: user.name, + profilePhoto: user.profilePhoto, + phone: user.phone, + isActive: user.isActive, + lastLogin: user.lastLogin, + createdAt: user.createdAt, + organizations: user.organizations + } + }; +} + +/** @type {import('./$types').Actions} */ +export const actions = { + updateProfile: async ({ request, locals }) => { + if (!locals.user) { + throw redirect(307, '/login'); + } + + const formData = await request.formData(); + const name = formData.get('name')?.toString(); + const phone = formData.get('phone')?.toString(); + + // Validate required fields + if (!name || name.trim().length === 0) { + return fail(400, { + error: 'Name is required', + data: { name, phone } + }); + } + + if (name.trim().length < 2) { + return fail(400, { + error: 'Name must be at least 2 characters long', + data: { name, phone } + }); + } + + // Validate phone if provided + let formattedPhone = null; + if (phone && phone.trim().length > 0) { + const phoneValidation = validatePhoneNumber(phone.trim()); + if (!phoneValidation.isValid) { + return fail(400, { + error: phoneValidation.error || 'Please enter a valid phone number', + data: { name, phone } + }); + } + formattedPhone = formatPhoneForStorage(phone.trim()); + } + + try { + await prisma.user.update({ + where: { + id: locals.user.id + }, + data: { + name: name.trim(), + phone: formattedPhone, + updatedAt: new Date() + } + }); + + return { + success: true, + message: 'Profile updated successfully' + }; + } catch (error) { + console.error('Error updating profile:', error); + return fail(500, { + error: 'Failed to update profile. Please try again.', + data: { name, phone } + }); + } + } +}; \ No newline at end of file diff --git a/src/routes/(app)/app/profile/+page.svelte b/src/routes/(app)/app/profile/+page.svelte new file mode 100644 index 0000000..5cf3ffd --- /dev/null +++ b/src/routes/(app)/app/profile/+page.svelte @@ -0,0 +1,353 @@ + + + + Profile - BottleCRM + + +
+ +
+
+
+

Profile

+

Manage your personal information and account settings

+
+ +
+
+ + + {#if form?.success} +
+
+
+ + + +
+
+

+ {form.message} +

+
+
+
+ {/if} + + {#if form?.error} +
+
+
+ + + +
+
+

+ {form.error} +

+
+
+
+ {/if} + +
+ +
+
+
+ +
+ {#if data.user.profilePhoto} + {data.user.name + {:else} +
+ + {getInitials(data.user.name)} + +
+ {/if} +
+ +

+ {data.user.name || 'Unnamed User'} +

+

{data.user.email}

+
+ + +
+ + {data.user.isActive ? 'Active' : 'Inactive'} + +
+
+
+ + +
+
+ {#if isEditing} + + +
+

Edit Profile Information

+ +
+ +
+ + +
+ + +
+ + + {#if phoneError} +

+ {phoneError} +

+ {/if} +
+
+
+ +
+
+ + +
+
+ + {:else} + +
+

Profile Information

+ +
+ +
+
+ + Email Address +
+
{data.user.email}
+
+ + +
+
+ + Phone Number +
+
+ {data.user.phone ? formatPhoneNumber(data.user.phone) : 'Not provided'} +
+
+ + +
+
+ + Last Login +
+
+ {formatDate(data.user.lastLogin)} +
+
+ + +
+
+ + Member Since +
+
+ {formatDate(data.user.createdAt)} +
+
+
+
+ {/if} +
+ + + {#if data.user.organizations && data.user.organizations.length > 0} +
+

Organizations

+
+ {#each data.user.organizations as userOrg} +
+
+
+ +
+
+

+ {userOrg.organization.name} +

+

+ Joined {formatDate(userOrg.joinedAt)} +

+
+
+ + {userOrg.role} + +
+ {/each} +
+
+ {/if} +
+
+
\ No newline at end of file diff --git a/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte b/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte index cf028b7..1ab40bd 100644 --- a/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte +++ b/src/routes/(app)/app/tasks/[task_id]/edit/+page.svelte @@ -41,7 +41,7 @@

diff --git a/src/routes/(app)/app/tasks/list/+page.svelte b/src/routes/(app)/app/tasks/list/+page.svelte index b7c8372..b5b8f45 100644 --- a/src/routes/(app)/app/tasks/list/+page.svelte +++ b/src/routes/(app)/app/tasks/list/+page.svelte @@ -118,17 +118,20 @@
- + {#snippet statusIcon(status)} + {@const StatusIcon = getStatusIcon(status)} + + {/snippet} + {@render statusIcon(task.status)}
- + {#snippet priorityIcon(priority)} + {@const PriorityIcon = getPriorityIcon(priority)} + + {/snippet} + {@render priorityIcon(task.priority)}
- + {#snippet statusIconCard(status)} + {@const StatusIcon = getStatusIcon(status)} + + {/snippet} + {@render statusIconCard(task.status)} - + {#snippet priorityIconCard(priority)} + {@const PriorityIcon = getPriorityIcon(priority)} + + {/snippet} + {@render priorityIconCard(task.priority)}
{/if} -
+ { + isSubmitting = true; + return async ({ update }) => { + await update(); + isSubmitting = false; + }; + }} + class="p-6" + > {#if urlAccountId} @@ -140,7 +153,7 @@