Fix importing the bill of materials
This commit is contained in:
@@ -19,13 +19,13 @@ interface JobCardProps {
|
|||||||
isTracked?: boolean;
|
isTracked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const JobCard: React.FC<JobCardProps> = ({
|
const JobCard: React.FC<JobCardProps> = ({
|
||||||
job,
|
job,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onUpdateProduced,
|
onUpdateProduced,
|
||||||
onImportBOM,
|
onImportBOM,
|
||||||
isTracked = false
|
isTracked = false
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isEditingProduced, setIsEditingProduced] = useState(false);
|
const [isEditingProduced, setIsEditingProduced] = useState(false);
|
||||||
@@ -33,10 +33,10 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
const [copyingBom, setCopyingBom] = useState(false);
|
const [copyingBom, setCopyingBom] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const sortedExpenditures = [...job.expenditures].sort((a, b) =>
|
const sortedExpenditures = [...job.expenditures].sort((a, b) =>
|
||||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
);
|
);
|
||||||
const sortedIncome = [...job.income].sort((a, b) =>
|
const sortedIncome = [...job.income].sort((a, b) =>
|
||||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,23 +95,34 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const importBillOfMaterials = async () => {
|
const importBillOfMaterials = async () => {
|
||||||
|
if (!onImportBOM) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Import functionality is not available",
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clipboardText = await navigator.clipboard.readText();
|
const clipboardText = await navigator.clipboard.readText();
|
||||||
const lines = clipboardText.split('\n').filter(line => line.trim());
|
const lines = clipboardText.split('\n').filter(line => line.trim());
|
||||||
const items: { name: string; quantity: number }[] = [];
|
const items: { name: string; quantity: number }[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const parts = line.trim().split(/\s+/);
|
const parts = line.trim().split(/[\s\t]+/);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
const name = parts.slice(0, -1).join(' ');
|
const name = parts.slice(0, -1).join(' ');
|
||||||
const quantity = parseInt(parts[parts.length - 1]);
|
const quantityPart = parts[parts.length - 1].replace(/,/g, '');
|
||||||
|
const quantity = parseInt(quantityPart);
|
||||||
if (name && !isNaN(quantity)) {
|
if (name && !isNaN(quantity)) {
|
||||||
items.push({ name, quantity });
|
items.push({ name, quantity });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length > 0 && onImportBOM) {
|
if (items.length > 0) {
|
||||||
onImportBOM(job.id, items);
|
onImportBOM(job.id, items);
|
||||||
toast({
|
toast({
|
||||||
title: "BOM Imported",
|
title: "BOM Imported",
|
||||||
@@ -150,7 +161,7 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
const text = job.billOfMaterials
|
const text = job.billOfMaterials
|
||||||
.map(item => `${item.name}\t${item.quantity.toLocaleString()}`)
|
.map(item => `${item.name}\t${item.quantity.toLocaleString()}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopyingBom(true);
|
setCopyingBom(true);
|
||||||
@@ -202,7 +213,7 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`bg-gray-900 border-gray-700 text-white h-full flex flex-col cursor-pointer hover:bg-gray-800/50 transition-colors ${job.status === 'Tracked' ? 'border-l-4 border-l-cyan-600' : ''}`}
|
className={`bg-gray-900 border-gray-700 text-white h-full flex flex-col cursor-pointer hover:bg-gray-800/50 transition-colors ${job.status === 'Tracked' ? 'border-l-4 border-l-cyan-600' : ''}`}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
@@ -232,7 +243,7 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
onClick={handleProducedClick}
|
onClick={handleProducedClick}
|
||||||
className={job.status !== 'Closed' ? "cursor-pointer hover:text-blue-400" : undefined}
|
className={job.status !== 'Closed' ? "cursor-pointer hover:text-blue-400" : undefined}
|
||||||
title={job.status !== 'Closed' ? "Click to edit" : undefined}
|
title={job.status !== 'Closed' ? "Click to edit" : undefined}
|
||||||
@@ -266,18 +277,18 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 justify-end">
|
<div className="flex gap-1 justify-end">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="p-1 h-6 w-6"
|
className="p-1 h-6 w-6"
|
||||||
onClick={handleImportClick}
|
onClick={handleImportClick}
|
||||||
title="Import BOM from clipboard"
|
title="Import BOM from clipboard"
|
||||||
>
|
>
|
||||||
<Import className="w-4 h-4 text-blue-400" />
|
<Import className="w-4 h-4 text-blue-400" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="p-1 h-6 w-6"
|
className="p-1 h-6 w-6"
|
||||||
onClick={handleExportClick}
|
onClick={handleExportClick}
|
||||||
disabled={!job.billOfMaterials?.length}
|
disabled={!job.billOfMaterials?.length}
|
||||||
@@ -359,8 +370,8 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
{job.projectedCost > 0 && (
|
{job.projectedCost > 0 && (
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-400">
|
||||||
vs Projected: {formatISK(job.projectedCost)}
|
vs Projected: {formatISK(job.projectedCost)}
|
||||||
<Badge
|
<Badge
|
||||||
variant={totalExpenditure <= job.projectedCost ? 'default' : 'destructive'}
|
variant={totalExpenditure <= job.projectedCost ? 'default' : 'destructive'}
|
||||||
className="ml-1 text-xs"
|
className="ml-1 text-xs"
|
||||||
>
|
>
|
||||||
{((totalExpenditure / job.projectedCost) * 100).toFixed(1)}%
|
{((totalExpenditure / job.projectedCost) * 100).toFixed(1)}%
|
||||||
@@ -377,8 +388,8 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
{job.projectedRevenue > 0 && (
|
{job.projectedRevenue > 0 && (
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-400">
|
||||||
vs Projected: {formatISK(job.projectedRevenue)}
|
vs Projected: {formatISK(job.projectedRevenue)}
|
||||||
<Badge
|
<Badge
|
||||||
variant={totalIncome >= job.projectedRevenue ? 'default' : 'destructive'}
|
variant={totalIncome >= job.projectedRevenue ? 'default' : 'destructive'}
|
||||||
className="ml-1 text-xs"
|
className="ml-1 text-xs"
|
||||||
>
|
>
|
||||||
{((totalIncome / job.projectedRevenue) * 100).toFixed(1)}%
|
{((totalIncome / job.projectedRevenue) * 100).toFixed(1)}%
|
||||||
@@ -399,8 +410,8 @@ const JobCard: React.FC<JobCardProps> = ({
|
|||||||
{job.projectedRevenue > 0 && job.projectedCost > 0 && (
|
{job.projectedRevenue > 0 && job.projectedCost > 0 && (
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-400">
|
||||||
vs Projected: {formatISK(job.projectedRevenue - job.projectedCost)}
|
vs Projected: {formatISK(job.projectedRevenue - job.projectedCost)}
|
||||||
<Badge
|
<Badge
|
||||||
variant={profit >= (job.projectedRevenue - job.projectedCost) ? 'default' : 'destructive'}
|
variant={profit >= (job.projectedRevenue - job.projectedCost) ? 'default' : 'destructive'}
|
||||||
className="ml-1 text-xs"
|
className="ml-1 text-xs"
|
||||||
>
|
>
|
||||||
{((profit / (job.projectedRevenue - job.projectedCost)) * 100).toFixed(1)}%
|
{((profit / (job.projectedRevenue - job.projectedCost)) * 100).toFixed(1)}%
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -17,8 +16,21 @@ interface JobFormProps {
|
|||||||
|
|
||||||
const formatDateForInput = (dateString: string | undefined | null): string => {
|
const formatDateForInput = (dateString: string | undefined | null): string => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
// Convert ISO string to datetime-local format (YYYY-MM-DDTHH:MM)
|
|
||||||
return new Date(dateString).toISOString().slice(0, 16);
|
// Create a date object in local timezone
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
// Format to YYYY-MM-DD
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
// Format to HH:MM
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
// Combine into format required by datetime-local (YYYY-MM-DDTHH:MM)
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const JobForm: React.FC<JobFormProps> = ({ job, onSubmit, onCancel }) => {
|
const JobForm: React.FC<JobFormProps> = ({ job, onSubmit, onCancel }) => {
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import JobCard from '@/components/JobCard';
|
import JobCard from '@/components/JobCard';
|
||||||
@@ -21,7 +20,8 @@ const JobDetails = () => {
|
|||||||
updateTransaction,
|
updateTransaction,
|
||||||
deleteTransaction,
|
deleteTransaction,
|
||||||
updateJob,
|
updateJob,
|
||||||
deleteJob
|
deleteJob,
|
||||||
|
createMultipleBillItems
|
||||||
} = useJobs();
|
} = useJobs();
|
||||||
|
|
||||||
const job = useJob(jobId || null);
|
const job = useJob(jobId || null);
|
||||||
@@ -99,6 +99,19 @@ const JobDetails = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImportBOM = async (jobId: string, items: { name: string; quantity: number }[]) => {
|
||||||
|
try {
|
||||||
|
const billItems = items.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: 0
|
||||||
|
}));
|
||||||
|
await createMultipleBillItems(jobId, billItems, 'billOfMaterials');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing BOM:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (showJobForm) {
|
if (showJobForm) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 p-6">
|
<div className="min-h-screen bg-gray-950 p-6">
|
||||||
@@ -139,6 +152,7 @@ const JobDetails = () => {
|
|||||||
onEdit={handleEditJob}
|
onEdit={handleEditJob}
|
||||||
onDelete={handleDeleteJob}
|
onDelete={handleDeleteJob}
|
||||||
onUpdateProduced={handleUpdateProduced}
|
onUpdateProduced={handleUpdateProduced}
|
||||||
|
onImportBOM={handleImportBOM}
|
||||||
/>
|
/>
|
||||||
<TransactionForm
|
<TransactionForm
|
||||||
jobId={job.id}
|
jobId={job.id}
|
||||||
|
@@ -6,5 +6,21 @@ export async function addBillItem(
|
|||||||
billItem: IndBillitemRecordNoId
|
billItem: IndBillitemRecordNoId
|
||||||
): Promise<IndBillitemRecord> {
|
): Promise<IndBillitemRecord> {
|
||||||
console.log('Adding bill item:', billItem);
|
console.log('Adding bill item:', billItem);
|
||||||
return await pb.collection<IndBillitemRecord>('ind_billItem').create(billItem);
|
// Set the job ID in the bill item record
|
||||||
|
const billItemWithJob = {
|
||||||
|
...billItem,
|
||||||
|
job: jobId
|
||||||
|
};
|
||||||
|
return await pb.collection<IndBillitemRecord>('ind_billItem').create(billItemWithJob);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBillItem(id: string): Promise<void> {
|
||||||
|
console.log('Deleting bill item:', id);
|
||||||
|
await pb.collection('ind_billItem').delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBillItems(ids: string[]): Promise<void> {
|
||||||
|
console.log('Deleting bill items:', ids);
|
||||||
|
// Delete items in parallel for better performance
|
||||||
|
await Promise.all(ids.map(id => deleteBillItem(id)));
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
import { IndJob } from '@/lib/types';
|
import { IndJob } from '@/lib/types';
|
||||||
import { IndJobRecord, IndJobRecordNoId, IndTransactionRecord, IndTransactionRecordNoId, IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes';
|
import { IndJobRecord, IndJobRecordNoId, IndTransactionRecord, IndTransactionRecordNoId, IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes';
|
||||||
import * as jobService from './jobService';
|
import * as jobService from './jobService';
|
||||||
@@ -219,6 +217,12 @@ export class DataService {
|
|||||||
const job = this.getJob(jobId);
|
const job = this.getJob(jobId);
|
||||||
if (!job) throw new Error(`Job with id ${jobId} not found`);
|
if (!job) throw new Error(`Job with id ${jobId} not found`);
|
||||||
|
|
||||||
|
// Delete existing bill items
|
||||||
|
const existingItemIds = job[type].map(item => item.id);
|
||||||
|
if (existingItemIds.length > 0) {
|
||||||
|
await billItemService.deleteBillItems(existingItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
const createdBillItems: IndBillitemRecord[] = [];
|
const createdBillItems: IndBillitemRecord[] = [];
|
||||||
|
|
||||||
// Create all bill items
|
// Create all bill items
|
||||||
@@ -227,17 +231,16 @@ export class DataService {
|
|||||||
createdBillItems.push(createdBillItem);
|
createdBillItems.push(createdBillItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the job's bill item references in one database call
|
// Update the job's bill item references with ONLY the new IDs
|
||||||
const currentIds = (job[type] || []).map(item => item.id);
|
|
||||||
const newIds = createdBillItems.map(item => item.id);
|
const newIds = createdBillItems.map(item => item.id);
|
||||||
await jobService.updateJob(jobId, {
|
await jobService.updateJob(jobId, {
|
||||||
[type]: [...currentIds, ...newIds]
|
[type]: newIds // Replace instead of append
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
const jobIndex = this.jobs.findIndex(j => j.id === jobId);
|
||||||
if (jobIndex !== -1) {
|
if (jobIndex !== -1) {
|
||||||
this.jobs[jobIndex][type].push(...createdBillItems);
|
this.jobs[jobIndex][type] = createdBillItems; // Replace instead of append
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
return this.jobs[jobIndex];
|
return this.jobs[jobIndex];
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user