mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:28:47 +02:00
Add manual salary entry
This commit is contained in:
@@ -60,6 +60,9 @@ services:
|
|||||||
- "5175:3000"
|
- "5175:3000"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
# Vite runs inside this container, so localhost would refer to the
|
||||||
|
# frontend rather than the Python service.
|
||||||
|
BACKEND_URL: http://backend:8000
|
||||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|||||||
177
frontend/src/components/SalaryEntryDialog.tsx
Normal file
177
frontend/src/components/SalaryEntryDialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { formatLocalDatetime } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SalaryEntryDialog({ onClose, onSaved }: Props) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
amount: '',
|
||||||
|
currency: 'CRC',
|
||||||
|
date: formatLocalDatetime(new Date()),
|
||||||
|
bank: 'BAC',
|
||||||
|
reference: '',
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const amount = Number(form.amount);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setError('Ingresá un monto mayor que cero.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.post('/transactions/', {
|
||||||
|
amount,
|
||||||
|
currency: form.currency,
|
||||||
|
date: form.date,
|
||||||
|
bank: form.bank,
|
||||||
|
merchant: 'Salario',
|
||||||
|
transaction_type: 'SALARY',
|
||||||
|
source: 'TRANSFER',
|
||||||
|
reference: form.reference.trim() || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
});
|
||||||
|
toast.success('Salario registrado');
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
const status = err && typeof err === 'object' && 'response' in err
|
||||||
|
? (err as { response: { status: number } }).response.status
|
||||||
|
: null;
|
||||||
|
setError(
|
||||||
|
status === 409
|
||||||
|
? 'Ya existe una transacción con ese comprobante.'
|
||||||
|
: 'No se pudo registrar el salario. Intentá de nuevo.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Registrar salario</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Guardá el depósito recibido mientras configurás una nueva automatización.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="salary-amount">Monto</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-amount"
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={form.amount}
|
||||||
|
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Moneda</Label>
|
||||||
|
<Select value={form.currency} onValueChange={(value) => value && setForm({ ...form, currency: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="CRC">CRC (₡)</SelectItem>
|
||||||
|
<SelectItem value="USD">USD ($)</SelectItem>
|
||||||
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="salary-date">Fecha del depósito</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.date}
|
||||||
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Banco</Label>
|
||||||
|
<Select value={form.bank} onValueChange={(value) => value && setForm({ ...form, bank: value })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="BAC">BAC</SelectItem>
|
||||||
|
<SelectItem value="BCR">BCR</SelectItem>
|
||||||
|
<SelectItem value="DAVIVIENDA">Davivienda</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="salary-reference">Comprobante</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-reference"
|
||||||
|
value={form.reference}
|
||||||
|
onChange={(e) => setForm({ ...form, reference: e.target.value })}
|
||||||
|
placeholder="Opcional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-2">
|
||||||
|
<Label htmlFor="salary-notes">Nota</Label>
|
||||||
|
<Input
|
||||||
|
id="salary-notes"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
|
placeholder="Ej. Quincena de julio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Registrar salario'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { Landmark, RefreshCw, Download } from 'lucide-react';
|
import { Landmark, RefreshCw, Download, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
|
import { type Transaction, getSalarios, getSalariosSummary } from '@/lib/api';
|
||||||
import { formatAmount, formatDate } from '@/lib/format';
|
import { formatAmount, formatDate } from '@/lib/format';
|
||||||
@@ -13,8 +13,10 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import SalaryEntryDialog from '@/components/SalaryEntryDialog';
|
||||||
|
|
||||||
export default function Salarios() {
|
export default function Salarios() {
|
||||||
|
const [entryOpen, setEntryOpen] = useState(false);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['salarios'],
|
queryKey: ['salarios'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -95,6 +97,10 @@ export default function Salarios() {
|
|||||||
subtitle="Historial de depósitos salariales"
|
subtitle="Historial de depósitos salariales"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
<Button size="sm" onClick={() => setEntryOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Registrar salario
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -152,6 +158,12 @@ export default function Salarios() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{entryOpen && (
|
||||||
|
<SalaryEntryDialog
|
||||||
|
onClose={() => setEntryOpen(false)}
|
||||||
|
onSaved={() => { void query.refetch(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import react from "@vitejs/plugin-react-oxc";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
// Browser requests arrive at Vite, which then proxies them to the backend.
|
||||||
|
// Locally the backend is published on the host; Docker Compose supplies the
|
||||||
|
// service hostname instead (see docker-compose.yml).
|
||||||
|
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8001";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -19,7 +24,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
// All other API calls → Python backend
|
// All other API calls → Python backend
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:8001",
|
target: backendUrl,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user