Add transaction tables to job details
Implemented transaction tables within the JobCard component to display and allow editing of existing expenditures and income.
This commit is contained in:
242
src/components/TransactionTable.tsx
Normal file
242
src/components/TransactionTable.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Transaction } from '@/services/jobService';
|
||||||
|
import { formatISK } from '@/utils/priceUtils';
|
||||||
|
import { Edit, Save, X, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TransactionTableProps {
|
||||||
|
title: string;
|
||||||
|
transactions: Transaction[];
|
||||||
|
type: 'expenditure' | 'income';
|
||||||
|
onUpdateTransaction: (transactionId: string, updates: Partial<Transaction>) => void;
|
||||||
|
onDeleteTransaction: (transactionId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransactionTable: React.FC<TransactionTableProps> = ({
|
||||||
|
title,
|
||||||
|
transactions,
|
||||||
|
type,
|
||||||
|
onUpdateTransaction,
|
||||||
|
onDeleteTransaction
|
||||||
|
}) => {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editingTransaction, setEditingTransaction] = useState<Transaction | null>(null);
|
||||||
|
|
||||||
|
const totalAmount = transactions.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0);
|
||||||
|
|
||||||
|
const handleEdit = (transaction: Transaction) => {
|
||||||
|
setEditingId(transaction.id);
|
||||||
|
setEditingTransaction({ ...transaction });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editingTransaction && editingId) {
|
||||||
|
onUpdateTransaction(editingId, editingTransaction);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingTransaction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditingTransaction(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (transactionId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this transaction?')) {
|
||||||
|
onDeleteTransaction(transactionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEditingField = (field: keyof Transaction, value: any) => {
|
||||||
|
if (editingTransaction) {
|
||||||
|
setEditingTransaction({
|
||||||
|
...editingTransaction,
|
||||||
|
[field]: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gray-900 border-gray-700 text-white">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<CardTitle className="text-blue-400">{title}</CardTitle>
|
||||||
|
<Badge variant={type === 'expenditure' ? 'destructive' : 'default'}>
|
||||||
|
Total: {formatISK(totalAmount)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-center py-4">No transactions yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-gray-700">
|
||||||
|
<TableHead className="text-gray-300">Date</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Item</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Qty</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Unit Price</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Total</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Buyer/Seller</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Location</TableHead>
|
||||||
|
<TableHead className="text-gray-300">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{transactions.map((transaction) => (
|
||||||
|
<TableRow key={transaction.id} className="border-gray-700">
|
||||||
|
<TableCell className="text-gray-300">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
value={editingTransaction?.date.toISOString().slice(0, 16)}
|
||||||
|
onChange={(e) => updateEditingField('date', new Date(e.target.value))}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
transaction.date.toLocaleString('sv-SE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).replace(' ', ' ')
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-white">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
value={editingTransaction?.itemName || ''}
|
||||||
|
onChange={(e) => updateEditingField('itemName', e.target.value)}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
transaction.itemName
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-300">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editingTransaction?.quantity || 0}
|
||||||
|
onChange={(e) => updateEditingField('quantity', parseInt(e.target.value) || 0)}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
transaction.quantity.toLocaleString()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-300">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={editingTransaction?.unitPrice || 0}
|
||||||
|
onChange={(e) => updateEditingField('unitPrice', parseFloat(e.target.value) || 0)}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
formatISK(transaction.unitPrice)
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={`font-semibold ${type === 'expenditure' ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={Math.abs(editingTransaction?.totalAmount || 0)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseFloat(e.target.value) || 0;
|
||||||
|
updateEditingField('totalAmount', type === 'expenditure' ? -Math.abs(value) : Math.abs(value));
|
||||||
|
}}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
formatISK(Math.abs(transaction.totalAmount))
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-300">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
value={editingTransaction?.buyer || ''}
|
||||||
|
onChange={(e) => updateEditingField('buyer', e.target.value)}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
transaction.buyer || '-'
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-300 max-w-32 truncate">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<Input
|
||||||
|
value={editingTransaction?.location || ''}
|
||||||
|
onChange={(e) => updateEditingField('location', e.target.value)}
|
||||||
|
className="bg-gray-800 border-gray-600 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span title={transaction.location}>{transaction.location || '-'}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{editingId === transaction.id ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="border-green-600 hover:bg-green-700 p-1"
|
||||||
|
>
|
||||||
|
<Save className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="border-gray-600 hover:bg-gray-700 p-1"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleEdit(transaction)}
|
||||||
|
className="border-gray-600 hover:bg-gray-700 p-1"
|
||||||
|
>
|
||||||
|
<Edit className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(transaction.id)}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionTable;
|
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -8,6 +7,7 @@ import { formatISK } from '@/utils/priceUtils';
|
|||||||
import JobCard from '@/components/JobCard';
|
import JobCard from '@/components/JobCard';
|
||||||
import JobForm from '@/components/JobForm';
|
import JobForm from '@/components/JobForm';
|
||||||
import TransactionForm from '@/components/TransactionForm';
|
import TransactionForm from '@/components/TransactionForm';
|
||||||
|
import TransactionTable from '@/components/TransactionTable';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
@@ -93,6 +93,46 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateTransaction = async (transactionId: string, updates: Partial<Transaction>) => {
|
||||||
|
if (!selectedJob) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobService.updateTransaction(selectedJob.id, transactionId, updates);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const updatedJob = { ...selectedJob };
|
||||||
|
updatedJob.expenditures = updatedJob.expenditures.map(tx =>
|
||||||
|
tx.id === transactionId ? { ...tx, ...updates } : tx
|
||||||
|
);
|
||||||
|
updatedJob.income = updatedJob.income.map(tx =>
|
||||||
|
tx.id === transactionId ? { ...tx, ...updates } : tx
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedJob(updatedJob);
|
||||||
|
setJobs(jobs.map(job => job.id === selectedJob.id ? updatedJob : job));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating transaction:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTransaction = async (transactionId: string) => {
|
||||||
|
if (!selectedJob) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jobService.deleteTransaction(selectedJob.id, transactionId);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
const updatedJob = { ...selectedJob };
|
||||||
|
updatedJob.expenditures = updatedJob.expenditures.filter(tx => tx.id !== transactionId);
|
||||||
|
updatedJob.income = updatedJob.income.filter(tx => tx.id !== transactionId);
|
||||||
|
|
||||||
|
setSelectedJob(updatedJob);
|
||||||
|
setJobs(jobs.map(job => job.id === selectedJob.id ? updatedJob : job));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting transaction:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const totalJobs = jobs.length;
|
const totalJobs = jobs.length;
|
||||||
const totalProfit = jobs.reduce((sum, job) => {
|
const totalProfit = jobs.reduce((sum, job) => {
|
||||||
const expenditure = job.expenditures.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0);
|
const expenditure = job.expenditures.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0);
|
||||||
@@ -124,7 +164,7 @@ const Index = () => {
|
|||||||
if (selectedJob) {
|
if (selectedJob) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 p-6">
|
<div className="min-h-screen bg-gray-950 p-6">
|
||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white">Job Details</h1>
|
<h1 className="text-3xl font-bold text-white">Job Details</h1>
|
||||||
@@ -150,6 +190,23 @@ const Index = () => {
|
|||||||
onTransactionsAdded={handleTransactionsAdded}
|
onTransactionsAdded={handleTransactionsAdded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<TransactionTable
|
||||||
|
title="Expenditures"
|
||||||
|
transactions={selectedJob.expenditures}
|
||||||
|
type="expenditure"
|
||||||
|
onUpdateTransaction={handleUpdateTransaction}
|
||||||
|
onDeleteTransaction={handleDeleteTransaction}
|
||||||
|
/>
|
||||||
|
<TransactionTable
|
||||||
|
title="Income"
|
||||||
|
transactions={selectedJob.income}
|
||||||
|
type="income"
|
||||||
|
onUpdateTransaction={handleUpdateTransaction}
|
||||||
|
onDeleteTransaction={handleDeleteTransaction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@@ -77,5 +77,15 @@ export const jobService = {
|
|||||||
async addTransaction(jobId: string, transaction: Omit<Transaction, 'id'>, type: 'expenditure' | 'income'): Promise<void> {
|
async addTransaction(jobId: string, transaction: Omit<Transaction, 'id'>, type: 'expenditure' | 'income'): Promise<void> {
|
||||||
// TODO: Implement with PocketBase
|
// TODO: Implement with PocketBase
|
||||||
console.log('Adding transaction:', jobId, transaction, type);
|
console.log('Adding transaction:', jobId, transaction, type);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTransaction(jobId: string, transactionId: string, updates: Partial<Transaction>): Promise<void> {
|
||||||
|
// TODO: Implement with PocketBase
|
||||||
|
console.log('Updating transaction:', jobId, transactionId, updates);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTransaction(jobId: string, transactionId: string): Promise<void> {
|
||||||
|
// TODO: Implement with PocketBase
|
||||||
|
console.log('Deleting transaction:', jobId, transactionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user