Skip to content
Merged

Dev #66

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ BottleCRM is a modern CRM application built with:
## Important Notes
- We need to ensure access control is strictly enforced based on user roles.
- No record should be accessible unless the user or the org has the appropriate permissions.
- When implementing forms in sveltekit A form label must be associated with a control
- When implementing forms in sveltekit A form label must be associated with a control
- svelte 5+ style coding standards should be followed
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ BottleCRM is a SaaS CRM platform built with SvelteKit, designed for startups and
- **Icons**: Lucide Svelte
- **Validation**: Zod
- **Package Manager**: pnpm
- **Type Checking**: JSDoc style type annotations (no TypeScript)

## Development Commands

Expand Down Expand Up @@ -91,6 +92,42 @@ npx prisma studio
- Use Zod for form validation
- Follow existing patterns in `/contacts`, `/leads`, `/accounts` for consistency

## Coding Standards

### Type Safety
- **NO TypeScript**: This project uses JavaScript with JSDoc style type annotations only
- **JSDoc Comments**: Use JSDoc syntax for type information and documentation
- **Type Checking**: Use `pnpm run check` to validate types via JSDoc annotations
- **Function Parameters**: Document parameter types using JSDoc `@param` tags
- **Return Types**: Document return types using JSDoc `@returns` tags

### JSDoc Examples
```javascript
/**
* Updates a contact in the database
* @param {string} contactId - The contact identifier
* @param {Object} updateData - The data to update
* @param {string} updateData.name - Contact name
* @param {string} updateData.email - Contact email
* @param {string} organizationId - Organization ID for data isolation
* @returns {Promise<Object>} The updated contact object
*/
async function updateContact(contactId, updateData, organizationId) {
// Implementation
}

/**
* @typedef {Object} User
* @property {string} id - User ID
* @property {string} email - User email
* @property {string} name - User name
* @property {string[]} organizationIds - Array of organization IDs
*/

/** @type {User|null} */
let currentUser = null;
```

## Security Requirements
- Never expose cross-organization data
- Always filter queries by user's organization membership
Expand Down
2 changes: 1 addition & 1 deletion src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
gtag('config', 'G-JNWHD22PPN');
</script>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
Expand Down
Binary file modified src/lib/assets/images/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/routes/(app)/app/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Plus
} from '@lucide/svelte';

/** @type {any} */
export let data;

$: metrics = data.metrics || {};
Expand Down
4 changes: 3 additions & 1 deletion src/routes/(app)/app/accounts/[accountId]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
Send
} from '@lucide/svelte';

/** @type {any} */
export let data;
/** @type {any} */
export let form;
let users = Array.isArray(data.users) ? data.users : [];

const { account, contacts, opportunities, quotes, tasks, cases } = data;
const { account, contacts, opportunities = [], quotes, tasks, cases } = data;
let comments = data.comments;

// Form state
Expand Down
15 changes: 15 additions & 0 deletions src/routes/(app)/app/opportunities/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
let sortDirection = $state('desc');
let showFilters = $state(false);
let showDeleteModal = $state(false);
/** @type {any} */
let opportunityToDelete = $state(null);
let deleteLoading = $state(false);

Expand Down Expand Up @@ -81,6 +82,10 @@
const filteredOpportunities = $derived(getFilteredOpportunities());


/**
* @param {number | null} amount
* @returns {string}
*/
function formatCurrency(amount) {
if (!amount) return '-';
return new Intl.NumberFormat('en-US', {
Expand All @@ -91,6 +96,10 @@
}).format(amount);
}

/**
* @param {string | Date | null} date
* @returns {string}
*/
function formatDate(date) {
if (!date) return '-';
return new Date(date).toLocaleDateString('en-US', {
Expand All @@ -100,6 +109,9 @@
});
}

/**
* @param {string} field
*/
function toggleSort(field) {
if (sortField === field) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
Expand All @@ -109,6 +121,9 @@
}
}

/**
* @param {any} opportunity
*/
function openDeleteModal(opportunity) {
opportunityToDelete = opportunity;
showDeleteModal = true;
Expand Down
24 changes: 22 additions & 2 deletions src/routes/(app)/app/opportunities/[opportunityId]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,40 @@
'CLOSED_LOST': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'
};

const getStageColor = (stage) => stageColors[stage] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
/**
* @param {string} stage
* @returns {string}
*/
const getStageColor = (stage) => stageColors[/** @type {keyof typeof stageColors} */ (stage)] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';

/**
* @param {number | null} amount
* @returns {string}
*/
const formatCurrency = (amount) => {
return amount ? `$${amount.toLocaleString()}` : 'N/A';
};

/**
* @param {string | Date | null} date
* @returns {string}
*/
const formatDate = (date) => {
return date ? new Date(date).toLocaleDateString() : 'N/A';
};

/**
* @param {string | Date | null} date
* @returns {string}
*/
const formatDateTime = (date) => {
return date ? new Date(date).toLocaleString() : 'N/A';
};

/**
* @param {string} stage
* @returns {number}
*/
const getStageProgress = (stage) => {
const stages = ['PROSPECTING', 'QUALIFICATION', 'PROPOSAL', 'NEGOTIATION', 'CLOSED_WON'];
const index = stages.indexOf(stage);
Expand Down Expand Up @@ -212,7 +232,7 @@
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Days to Close</span>
<span class="font-semibold text-gray-900 dark:text-white">
{opportunity.closeDate ? Math.ceil((new Date(opportunity.closeDate) - new Date()) / (1000 * 60 * 60 * 24)) : 'N/A'}
{opportunity.closeDate ? Math.ceil((new Date(opportunity.closeDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)) : 'N/A'}
</span>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { error, fail, redirect } from '@sveltejs/kit';
import prisma from '$lib/prisma';

/**
* @param {Object} options
* @param {Record<string, string>} options.params
* @param {App.Locals} options.locals
*/
export async function load({ params, locals }) {
if (!locals.org?.id) {
throw error(403, 'Organization access required');
Expand All @@ -26,23 +31,30 @@ export async function load({ params, locals }) {
}

export const actions = {
/**
* @param {Object} options
* @param {Request} options.request
* @param {Record<string, string>} options.params
* @param {App.Locals} options.locals
*/
default: async ({ request, params, locals }) => {
if (!locals.org?.id) {
return fail(403, { error: 'Organization access required' });
}

const formData = await request.formData();
const status = formData.get('status');
const closeDate = formData.get('closeDate');
const closeReason = formData.get('closeReason');
const status = formData.get('status')?.toString();
const closeDate = formData.get('closeDate')?.toString();
const closeReason = formData.get('closeReason')?.toString();

// Validate required fields
if (!status || !closeDate) {
return fail(400, { error: 'Status and close date are required' });
}

// Validate status
if (!['CLOSED_WON', 'CLOSED_LOST'].includes(status)) {
const validCloseStatuses = ['CLOSED_WON', 'CLOSED_LOST'];
if (!status || !validCloseStatuses.includes(status)) {
return fail(400, { error: 'Invalid status selected' });
}

Expand All @@ -63,12 +75,14 @@ export const actions = {
}

// Update the opportunity with closing details
const updatedOpportunity = await prisma.opportunity.update({
const opportunityStage = /** @type {import('@prisma/client').OpportunityStage} */ (status);

await prisma.opportunity.update({
where: { id: params.opportunityId },
data: {
stage: status, // CLOSED_WON or CLOSED_LOST
stage: opportunityStage, // CLOSED_WON or CLOSED_LOST
status: status === 'CLOSED_WON' ? 'SUCCESS' : 'FAILED',
closeDate: new Date(closeDate),
closeDate: closeDate ? new Date(closeDate) : null,
description: closeReason ?
(opportunity.description ? `${opportunity.description}\n\nClose Reason: ${closeReason}` : `Close Reason: ${closeReason}`)
: opportunity.description,
Expand Down Expand Up @@ -97,7 +111,7 @@ export const actions = {
throw redirect(303, `/app/opportunities/${opportunity.id}`);
} catch (err) {
console.error('Error closing opportunity:', err);
if (err.status === 303) {
if (err && typeof err === 'object' && 'status' in err && err.status === 303) {
throw err; // Re-throw redirect
}
return fail(500, { error: 'Failed to close opportunity. Please try again.' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,6 @@
{ value: 'CLOSED_LOST', label: 'Closed Lost', color: 'text-red-600' }
];

function handleSubmit() {
return async ({ update }) => {
isSubmitting = true;
await update();
isSubmitting = false;
};
}
</script>

<div class="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
Expand Down Expand Up @@ -77,7 +70,13 @@
<h2 class="text-lg font-medium text-gray-900 dark:text-white">Close Opportunity</h2>
</div>

<form method="POST" use:enhance={handleSubmit} class="p-6 space-y-6">
<form method="POST" use:enhance={() => {
return async ({ update }) => {
isSubmitting = true;
await update();
isSubmitting = false;
};
}} class="p-6 space-y-6">
{#if form?.error}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div class="flex items-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { error, fail, redirect } from '@sveltejs/kit';
import prisma from '$lib/prisma';

export async function load({ params }) {
const opportunity = await prisma.opportunity.findUnique({
where: { id: params.opportunityId },
/**
* @param {Object} options
* @param {Record<string, string>} options.params
* @param {App.Locals} options.locals
*/
export async function load({ params, locals }) {
if (!locals.org?.id) {
throw error(403, 'Organization access required');
}

const opportunity = await prisma.opportunity.findFirst({
where: {
id: params.opportunityId,
organizationId: locals.org.id
},
include: {
account: {
select: {
Expand All @@ -28,15 +40,26 @@ export async function load({ params }) {
}

export const actions = {
default: async ({ request, params }) => {
/**
* @param {Object} options
* @param {Request} options.request
* @param {Record<string, string>} options.params
* @param {App.Locals} options.locals
*/
default: async ({ request, params, locals }) => {
if (!locals.org?.id) {
return fail(403, { error: 'Organization access required' });
}

const form = await request.formData();

const name = form.get('name')?.toString().trim();
const amount = form.get('amount') ? parseFloat(form.get('amount')) : null;
const expectedRevenue = form.get('expectedRevenue') ? parseFloat(form.get('expectedRevenue')) : null;
const amount = form.get('amount') ? parseFloat(form.get('amount')?.toString() || '') : null;
const expectedRevenue = form.get('expectedRevenue') ? parseFloat(form.get('expectedRevenue')?.toString() || '') : null;
const stage = form.get('stage')?.toString();
const probability = form.get('probability') ? parseFloat(form.get('probability')) : null;
const closeDate = form.get('closeDate') ? new Date(form.get('closeDate')) : null;
const probability = form.get('probability') ? parseFloat(form.get('probability')?.toString() || '') : null;
const closeDateValue = form.get('closeDate')?.toString();
const closeDate = closeDateValue ? new Date(closeDateValue) : null;
const leadSource = form.get('leadSource')?.toString() || null;
const forecastCategory = form.get('forecastCategory')?.toString() || null;
const type = form.get('type')?.toString() || null;
Expand All @@ -51,6 +74,12 @@ export const actions = {
return fail(400, { message: 'Stage is required.' });
}

// Validate stage is a valid enum value
const validStages = ['PROSPECTING', 'QUALIFICATION', 'PROPOSAL', 'NEGOTIATION', 'CLOSED_WON', 'CLOSED_LOST'];
if (!validStages.includes(stage)) {
return fail(400, { message: 'Invalid stage selected.' });
}

// Validate probability range
if (probability !== null && (probability < 0 || probability > 100)) {
return fail(400, { message: 'Probability must be between 0 and 100.' });
Expand All @@ -66,13 +95,27 @@ export const actions = {
}

try {
// Verify the opportunity exists and belongs to the organization
const existingOpportunity = await prisma.opportunity.findFirst({
where: {
id: params.opportunityId,
organizationId: locals.org.id
}
});

if (!existingOpportunity) {
return fail(404, { message: 'Opportunity not found' });
}

const opportunityStage = /** @type {import('@prisma/client').OpportunityStage} */ (stage);

await prisma.opportunity.update({
where: { id: params.opportunityId },
data: {
name,
amount,
expectedRevenue,
stage,
stage: opportunityStage,
probability,
closeDate,
leadSource,
Expand Down
Loading