Added edit dialogs for time &

X-Lovable-Edit-ID: edt-86529e08-7279-47b0-8072-84b0d1323c2e
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-29 20:30:34 +00:00
co-authored by renee-png
2 changed files with 268 additions and 2 deletions
+142 -1
View File
@@ -6,7 +6,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Plus, Trash2, Loader2, Receipt as ReceiptIcon, CheckCircle2, Undo2, Download, RefreshCw } from "lucide-react";
import { Plus, Trash2, Loader2, Receipt as ReceiptIcon, CheckCircle2, Undo2, Download, RefreshCw, Pencil } from "lucide-react";
import { formatCurrency, formatDate } from "@/lib/format";
import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation";
import { toast } from "sonner";
@@ -19,6 +19,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
interface FeeItem {
id: string;
@@ -47,6 +48,15 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
const [fees, setFees] = useState<FeeItem[]>([]);
const [selectedFeeId, setSelectedFeeId] = useState<string>("");
const [showImport, setShowImport] = useState(false);
const [staff, setStaff] = useState<Array<{ id: string; full_name: string | null; email: string | null }>>([]);
const [editItem, setEditItem] = useState<any | null>(null);
useEffect(() => {
(async () => {
const { data } = await supabase.from("profiles").select("id, full_name, email").order("full_name");
setStaff((data ?? []) as any);
})();
}, []);
const load = async () => {
const { data, error } = await supabase
@@ -356,6 +366,7 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
</Button>
)}
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => setEditItem(e)} title="Edit"><Pencil className="h-4 w-4" /></Button>}
</div>
</td>
</tr>
@@ -373,6 +384,14 @@ export function CaseExpensesTab({ caseId }: { caseId: string }) {
clientId={clientId}
onImported={load}
/>
<EditExpenseDialog
item={editItem}
staff={staff}
currentUserId={user?.id ?? ""}
onClose={() => setEditItem(null)}
onSaved={() => { setEditItem(null); load(); }}
/>
</div>
);
}
@@ -385,3 +404,125 @@ function Stat({ label, value }: { label: string; value: string }) {
</div>
);
}
function EditExpenseDialog({
item,
staff,
currentUserId,
onClose,
onSaved,
}: {
item: any | null;
staff: Array<{ id: string; full_name: string | null; email: string | null }>;
currentUserId: string;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState({
expense_date: "",
user_id: "",
description: "",
quantity: "1",
unit_price: "",
billable: true,
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (item) {
setForm({
expense_date: item.expense_date ?? "",
user_id: item.user_id ?? currentUserId,
description: item.description ?? "",
quantity: String(item.quantity ?? 1),
unit_price: String(item.unit_price ?? item.amount ?? ""),
billable: !!item.billable,
});
}
}, [item, currentUserId]);
const save = async () => {
if (!item) return;
const quantity = parseFloat(form.quantity);
const unit_price = parseFloat(form.unit_price);
if (isNaN(quantity) || quantity <= 0) { toast.error("Quantity must be > 0"); return; }
if (isNaN(unit_price) || unit_price < 0) { toast.error("Invalid unit price"); return; }
if (!form.description.trim()) { toast.error("Description required"); return; }
const amount = +(quantity * unit_price).toFixed(2);
setSaving(true);
const { error } = await supabase
.from("expenses")
.update({
expense_date: form.expense_date,
user_id: form.user_id,
description: form.description.trim(),
quantity,
unit_price,
amount,
billable: form.billable,
})
.eq("id", item.id);
setSaving(false);
if (error) { toast.error(error.message); return; }
toast.success("Expense updated");
onSaved();
};
return (
<Dialog open={!!item} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit expense</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date</Label>
<Input type="date" value={form.expense_date} onChange={(e) => setForm({ ...form, expense_date: e.target.value })} />
</div>
<div>
<Label>Entered by</Label>
<Select value={form.user_id} onValueChange={(v) => setForm({ ...form, user_id: v })}>
<SelectTrigger><SelectValue placeholder="Select staff…" /></SelectTrigger>
<SelectContent>
{staff.map((s) => (
<SelectItem key={s.id} value={s.id}>
{(s.full_name || s.email || "Unknown") + (s.id === currentUserId ? " (me)" : "")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<Label>Qty</Label>
<Input type="number" step="any" min="0" value={form.quantity} onChange={(e) => setForm({ ...form, quantity: e.target.value })} />
</div>
<div>
<Label>Unit price ($)</Label>
<Input type="number" step="0.01" min="0" value={form.unit_price} onChange={(e) => setForm({ ...form, unit_price: e.target.value })} />
</div>
<div>
<Label>Total</Label>
<Input readOnly tabIndex={-1} className="bg-muted/40"
value={`$${((parseFloat(form.quantity) || 0) * (parseFloat(form.unit_price) || 0)).toFixed(2)}`} />
</div>
</div>
<div>
<Label>Description</Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} maxLength={500} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+126 -1
View File
@@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Plus, Trash2, Loader2, FilePlus, PlusCircle, CheckCircle2, Undo2, Download } from "lucide-react";
import { Plus, Trash2, Loader2, FilePlus, PlusCircle, CheckCircle2, Undo2, Download, Pencil } from "lucide-react";
import { formatCurrency, formatDate } from "@/lib/format";
import { ensureMarkedInvoicedPlaceholder } from "@/lib/invoice-generation";
import { roundToTenth } from "@/lib/timer";
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/select";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { NewFeeItemDialog } from "@/components/fees/new-fee-item-dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
interface FeeItem {
id: string;
@@ -61,6 +62,7 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
const [selectedFeeId, setSelectedFeeId] = useState<string>("");
const [showNewFee, setShowNewFee] = useState(false);
const [showImport, setShowImport] = useState(false);
const [editEntry, setEditEntry] = useState<any | null>(null);
useEffect(() => {
(async () => {
@@ -354,6 +356,7 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
</Button>
)}
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => del(e.id)}><Trash2 className="h-4 w-4 text-destructive" /></Button>}
{!e.invoice_id && <Button variant="ghost" size="icon" onClick={() => setEditEntry(e)} title="Edit"><Pencil className="h-4 w-4" /></Button>}
</div>
</td>
</tr>
@@ -393,6 +396,14 @@ export function CaseTimeTab({ caseRecord, onInvoice }: CaseTimeTabProps) {
clientId={caseRecord.client_id ?? null}
onImported={load}
/>
<EditTimeEntryDialog
entry={editEntry}
staff={staff}
currentUserId={user?.id ?? ""}
onClose={() => setEditEntry(null)}
onSaved={() => { setEditEntry(null); load(); }}
/>
</div>
);
}
@@ -405,3 +416,117 @@ function Stat({ label, value }: { label: string; value: string }) {
</div>
);
}
function EditTimeEntryDialog({
entry,
staff,
currentUserId,
onClose,
onSaved,
}: {
entry: any | null;
staff: Array<{ id: string; full_name: string | null; email: string | null; hourly_rate: number | null }>;
currentUserId: string;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState({
work_date: "",
user_id: "",
hours: "",
hourly_rate: "",
description: "",
billable: true,
});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (entry) {
setForm({
work_date: entry.work_date ?? "",
user_id: entry.user_id ?? currentUserId,
hours: String(entry.hours ?? ""),
hourly_rate: String(entry.hourly_rate ?? ""),
description: entry.description ?? "",
billable: !!entry.billable,
});
}
}, [entry, currentUserId]);
const save = async () => {
if (!entry) return;
const hours = roundToTenth(parseFloat(form.hours || "0") || 0);
const rate = parseFloat(form.hourly_rate || "0") || 0;
if (hours <= 0) { toast.error("Hours must be greater than 0"); return; }
if (!form.description.trim()) { toast.error("Description required"); return; }
setSaving(true);
const { error } = await supabase
.from("time_entries")
.update({
work_date: form.work_date,
user_id: form.user_id,
hours,
hourly_rate: rate,
description: form.description.trim(),
billable: form.billable,
})
.eq("id", entry.id);
setSaving(false);
if (error) { toast.error(error.message); return; }
toast.success("Time entry updated");
onSaved();
};
return (
<Dialog open={!!entry} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent>
<DialogHeader><DialogTitle>Edit time entry</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date</Label>
<Input type="date" value={form.work_date} onChange={(e) => setForm({ ...form, work_date: e.target.value })} />
</div>
<div>
<Label>Entered by</Label>
<Select value={form.user_id} onValueChange={(v) => setForm({ ...form, user_id: v })}>
<SelectTrigger><SelectValue placeholder="Select staff…" /></SelectTrigger>
<SelectContent>
{staff.map((s) => (
<SelectItem key={s.id} value={s.id}>
{(s.full_name || s.email || "Unknown") + (s.id === currentUserId ? " (me)" : "")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Hours (decimal)</Label>
<Input type="number" step="0.1" min="0" value={form.hours} onChange={(e) => setForm({ ...form, hours: e.target.value })} />
</div>
<div>
<Label>Hourly rate ($)</Label>
<Input type="number" step="0.01" min="0" value={form.hourly_rate} onChange={(e) => setForm({ ...form, hourly_rate: e.target.value })} />
</div>
</div>
<div>
<Label>Description</Label>
<Textarea rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} maxLength={1000} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.billable} onCheckedChange={(c) => setForm({ ...form, billable: !!c })} />
Billable
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={save} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}