initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:37 +08:00
commit 3f5bf0e118
88 changed files with 10997 additions and 0 deletions

View File

@@ -0,0 +1,165 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Area { id: string; name: string; }
interface Plan { id: string; name: string; price: string; speedDown: number; speedUp: number; }
interface CreateClientModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function CreateClientModal({ open, onClose, onSuccess }: CreateClientModalProps) {
const { toast } = useToast();
const [areas, setAreas] = useState<Area[]>([]);
const [plans, setPlans] = useState<Plan[]>([]);
const [form, setForm] = useState({
firstName: '', lastName: '', email: '', phone: '', address: '', areaId: '',
planId: '', subscriptionType: 'postpaid',
});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
api.get('/areas').then((r) => setAreas(r.data.data)).catch(() => {});
api.get('/plans').then((r) => setPlans(r.data.data)).catch(() => {});
setForm({ firstName: '', lastName: '', email: '', phone: '', address: '', areaId: '', planId: '', subscriptionType: 'postpaid' });
}
}, [open]);
const selectedPlan = plans.find((p) => p.id === form.planId);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.planId) { toast('Please select a plan', 'error'); return; }
setSubmitting(true);
try {
await api.post('/clients', {
...form,
email: form.email || undefined,
phone: form.phone || undefined,
areaId: form.areaId || undefined,
});
toast('Client onboarded! Installation ticket created.', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to onboard client', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="Onboard New Client" description="Register client, assign plan, and start the installation workflow." wide>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Client Info */}
<div>
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Client Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="c-first" className="block text-sm font-medium text-surface-700 dark:text-surface-300">First Name</label>
<input id="c-first" type="text" required value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} className={inputClass} />
</div>
<div>
<label htmlFor="c-last" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Last Name</label>
<input id="c-last" type="text" required value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} className={inputClass} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label htmlFor="c-email" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Email <span className="text-surface-400 font-normal">(optional)</span></label>
<input id="c-email" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
</div>
<div>
<label htmlFor="c-phone" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Phone <span className="text-surface-400 font-normal">(optional)</span></label>
<input id="c-phone" type="text" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} className={inputClass} placeholder="09171234567" />
</div>
</div>
<div className="mt-3">
<label htmlFor="c-addr" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Address</label>
<input id="c-addr" type="text" required minLength={5} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} className={inputClass} placeholder="Street, Barangay, City" />
</div>
<div className="mt-3">
<label htmlFor="c-area" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Area <span className="text-surface-400 font-normal">(optional)</span></label>
<select id="c-area" value={form.areaId} onChange={(e) => setForm({ ...form, areaId: e.target.value })} className={inputClass}>
<option value="">No area assigned</option>
{areas.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
</div>
{/* Plan & Subscription */}
<div className="border-t border-surface-200 pt-5">
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Subscription Plan <span className="text-red-400">*</span></h3>
<div className="flex gap-3 mb-4">
{(['postpaid', 'prepaid'] as const).map((t) => (
<button key={t} type="button" onClick={() => setForm({ ...form, subscriptionType: t })}
className={`flex-1 px-4 py-3 rounded-lg border text-sm font-medium transition-all duration-200 cursor-pointer text-left ${
form.subscriptionType === t
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 ring-2 ring-primary-500/20'
: 'border-surface-200 dark:border-surface-600 text-surface-500 dark:text-surface-400 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
<span className="block font-semibold">{t === 'postpaid' ? 'Postpaid' : 'Prepaid'}</span>
<span className="block text-[11px] font-normal mt-0.5 text-surface-400">
{t === 'postpaid' ? 'Install → Activate → Invoice after 1 month' : 'Install → Pay first → Then activate'}
</span>
</button>
))}
</div>
<label className="block text-sm font-medium text-surface-700 mb-2">Select Plan</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{plans.map((p) => (
<button key={p.id} type="button" onClick={() => setForm({ ...form, planId: p.id })}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
form.planId === p.id
? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20 ring-2 ring-primary-500/20'
: 'border-surface-200 dark:border-surface-600 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
<div className="flex items-center justify-between">
<div>
<span className="font-medium text-surface-800 dark:text-surface-200">{p.name}</span>
<span className="ml-2 text-xs text-surface-500">{p.speedDown}/{p.speedUp} Mbps</span>
</div>
<span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(p.price).toLocaleString()}/mo</span>
</div>
</button>
))}
{plans.length === 0 && (
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700">
No plans available. Create plans in Settings first.
</div>
)}
</div>
</div>
{/* Summary */}
{selectedPlan && (
<div className="bg-surface-50 dark:bg-surface-800 rounded-lg p-4 text-sm space-y-1">
<p className="font-medium text-surface-800 dark:text-surface-200">Onboarding Summary</p>
<p className="text-surface-600 dark:text-surface-400">Plan: {selectedPlan.name} PHP {Number(selectedPlan.price).toLocaleString()}/mo ({form.subscriptionType})</p>
<p className="text-surface-500 text-xs">
{form.subscriptionType === 'postpaid'
? 'Install ticket → resolve → Activation ticket → resolve → Active + 1st invoice (due 1 month)'
: 'Install ticket → resolve → 1st invoice → pay → Activation ticket → resolve → Active + next invoice'}
</p>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!form.planId}>Onboard Client</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
const CATEGORIES = ['utilities', 'supplies', 'salary', 'maintenance', 'transport', 'equipment', 'other'];
interface CreateExpenseModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function CreateExpenseModal({ open, onClose, onSuccess }: CreateExpenseModalProps) {
const { toast } = useToast();
const [form, setForm] = useState({ category: 'utilities', description: '', amount: 0, notes: '' });
const [submitting, setSubmitting] = useState(false);
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
useEffect(() => { if (open) setForm({ category: 'utilities', description: '', amount: 0, notes: '' }); }, [open]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/expenses', { ...form, notes: form.notes || undefined }); toast('Expense submitted', 'success'); onSuccess(); onClose(); }
catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="New Expense" description="Submit an expense for approval">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Category</label>
<select value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} className={ic}>
{CATEGORIES.map((c) => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
</select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Amount (PHP)</label><input type="number" required min={1} step={0.01} value={form.amount || ''} onChange={(e) => setForm({ ...form, amount: parseFloat(e.target.value) || 0 })} className={ic} /></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description</label><input type="text" required minLength={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className={ic} placeholder="What was the expense for?" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Notes <span className="text-surface-400 font-normal">(optional)</span></label><input type="text" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} className={ic} /></div>
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button><Button type="submit" loading={submitting}>Submit Expense</Button></div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Plan { id: string; name: string; price: string; speedDown: number; speedUp: number; }
interface Props {
open: boolean;
onClose: () => void;
onSuccess: () => void;
clientId: string;
clientName: string;
}
export function CreateSubscriptionModal({ open, onClose, onSuccess, clientId, clientName }: Props) {
const { toast } = useToast();
const [plans, setPlans] = useState<Plan[]>([]);
const [planId, setPlanId] = useState('');
const [type, setType] = useState('postpaid');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) {
api.get('/plans').then((r) => setPlans(r.data.data)).catch(() => {});
setPlanId('');
setType('postpaid');
}
}, [open]);
const selectedPlan = plans.find((p) => p.id === planId);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!planId) { toast('Please select a plan', 'error'); return; }
setSubmitting(true);
try {
await api.post('/subscriptions', { clientId, planId, type });
toast('Subscription created', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to create subscription', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="New Subscription" description={`Create a subscription for ${clientName}`}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Plan</label>
<div className="space-y-2 max-h-48 overflow-y-auto">
{plans.map((p) => (
<button key={p.id} type="button" onClick={() => setPlanId(p.id)}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
planId === p.id ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20 ring-2 ring-primary-500/20' : 'border-surface-200 dark:border-surface-600 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
<div className="flex items-center justify-between">
<span className="font-medium text-surface-800 dark:text-surface-200">{p.name}</span>
<span className="font-medium text-surface-900 dark:text-surface-100">PHP {Number(p.price).toLocaleString()}</span>
</div>
<p className="text-xs text-surface-500 mt-0.5">{p.speedDown}/{p.speedUp} Mbps</p>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Type</label>
<div className="flex gap-3">
{(['postpaid', 'prepaid'] as const).map((t) => (
<button key={t} type="button" onClick={() => setType(t)}
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-all duration-200 cursor-pointer ${
type === t ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400' : 'border-surface-200 dark:border-surface-600 text-surface-500 dark:text-surface-400 hover:border-surface-300 dark:hover:border-surface-500'
}`}>
{t === 'postpaid' ? 'Postpaid' : 'Prepaid'}
<p className="text-[11px] font-normal mt-0.5 text-surface-400">
{t === 'postpaid' ? 'Use first, pay later' : 'Pay first, then activate'}
</p>
</button>
))}
</div>
</div>
{selectedPlan && (
<div className="bg-surface-50 dark:bg-surface-800 rounded-lg p-3 text-sm">
<p className="text-surface-500">Summary: <span className="font-medium text-surface-800">{selectedPlan.name}</span> PHP {Number(selectedPlan.price).toLocaleString()}/month ({type})</p>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!planId}>Create Subscription</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface Props {
open: boolean;
onClose: () => void;
onSuccess: () => void;
prefillClientId?: string;
prefillClientName?: string;
}
export function CreateTicketModal({ open, onClose, onSuccess, prefillClientId, prefillClientName }: Props) {
const { toast } = useToast();
const [clients, setClients] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [clientSearch, setClientSearch] = useState(prefillClientName || '');
const [selectedClientId, setSelectedClientId] = useState(prefillClientId || '');
const [showDropdown, setShowDropdown] = useState(false);
const [form, setForm] = useState({ type: 'support', title: '', description: '', priority: 'normal', assigneeId: '' });
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
api.get('/clients?limit=100').then((r) => {
const d = r.data.data;
setClients(Array.isArray(d) ? d : d.items);
}).catch(() => {});
// Try to load users for assignee (may fail for non-admin)
api.get('/users').then((r) => setUsers(r.data.data)).catch(() => {});
setForm({ type: 'support', title: '', description: '', priority: 'normal', assigneeId: '' });
if (prefillClientId) {
setSelectedClientId(prefillClientId);
setClientSearch(prefillClientName || '');
}
}, [open, prefillClientId, prefillClientName]);
const filteredClients = useMemo(() => {
if (!clientSearch || selectedClientId) return [];
const q = clientSearch.toLowerCase();
return clients.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.accountNumber.toLowerCase().includes(q),
).slice(0, 6);
}, [clientSearch, clients, selectedClientId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.title.trim()) { toast('Title is required', 'error'); return; }
setSubmitting(true);
try {
await api.post('/tickets', {
...form,
clientId: selectedClientId || undefined,
assigneeId: form.assigneeId || undefined,
description: form.description || undefined,
});
toast('Ticket created', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to create ticket', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="Create Ticket" description="Create a support, maintenance, or custom ticket">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Client search */}
{!prefillClientId && (
<div className="relative">
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Client <span className="text-surface-400 font-normal">(optional)</span></label>
{selectedClientId ? (
<div className="flex items-center justify-between px-3.5 py-2.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-700">
<span className="text-sm text-surface-800 dark:text-surface-200">{clientSearch}</span>
<button type="button" onClick={() => { setSelectedClientId(''); setClientSearch(''); }} className="text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 cursor-pointer" aria-label="Clear">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M3 3l8 8M11 3l-8 8" /></svg>
</button>
</div>
) : (
<div>
<input type="text" value={clientSearch} onChange={(e) => { setClientSearch(e.target.value); setShowDropdown(true); }}
onFocus={() => setShowDropdown(true)} placeholder="Search client..." className={inputClass} />
{showDropdown && filteredClients.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg max-h-40 overflow-y-auto">
{filteredClients.map((c: any) => (
<button key={c.id} type="button" onClick={() => { setSelectedClientId(c.id); setClientSearch(`${c.firstName} ${c.lastName}`); setShowDropdown(false); }}
className="w-full text-left px-4 py-2 hover:bg-surface-50 dark:hover:bg-surface-700 text-sm cursor-pointer dark:text-surface-300">{c.firstName} {c.lastName} <span className="text-surface-400 font-mono text-xs">{c.accountNumber}</span></button>
))}
</div>
)}
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="tk-type" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Type</label>
<select id="tk-type" value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} className={inputClass}>
<option value="support">Support</option>
<option value="maintenance">Maintenance</option>
<option value="installation">Installation</option>
<option value="activation">Activation</option>
</select>
</div>
<div>
<label htmlFor="tk-priority" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Priority</label>
<select id="tk-priority" value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })} className={inputClass}>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
<div>
<label htmlFor="tk-title" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Title</label>
<input id="tk-title" type="text" required minLength={3} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
className={inputClass} placeholder="Brief description of the issue" />
</div>
<div>
<label htmlFor="tk-desc" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description <span className="text-surface-400 font-normal">(optional)</span></label>
<textarea id="tk-desc" rows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })}
className={`${inputClass} resize-none`} placeholder="Detailed description..." />
</div>
{users.length > 0 && (
<div>
<label htmlFor="tk-assignee" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Assign To <span className="text-surface-400 font-normal">(optional)</span></label>
<select id="tk-assignee" value={form.assigneeId} onChange={(e) => setForm({ ...form, assigneeId: e.target.value })} className={inputClass}>
<option value="">Unassigned</option>
{users.map((u: any) => <option key={u.id} value={u.id}>{u.firstName} {u.lastName} ({u.roles.join(', ')})</option>)}
</select>
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting}>Create Ticket</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
interface Invoice {
id: string;
number: string;
amount: string;
balance: string;
status: string;
dueDate: string;
client: { id: string; firstName: string; lastName: string; accountNumber: string };
}
interface Client {
id: string;
firstName: string;
lastName: string;
accountNumber: string;
}
interface PaymentModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
prefillClientId?: string;
prefillClientName?: string;
prefillInvoice?: Invoice;
}
export function PaymentModal({
open,
onClose,
onSuccess,
prefillClientId,
prefillClientName,
prefillInvoice,
}: PaymentModalProps) {
const { toast } = useToast();
const [clients, setClients] = useState<Client[]>([]);
const [clientSearch, setClientSearch] = useState(prefillClientName || '');
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [selectedInvoice, setSelectedInvoice] = useState<Invoice | null>(prefillInvoice || null);
const [amount, setAmount] = useState<number>(0);
const [method, setMethod] = useState('cash');
const [referenceNo, setReferenceNo] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [showClientDropdown, setShowClientDropdown] = useState(false);
// Load clients for search
useEffect(() => {
if (!open) return;
api.get('/clients?limit=100').then((r) => {
const d = r.data.data;
setClients(Array.isArray(d) ? d : d.items);
}).catch(() => {});
}, [open]);
// Pre-fill client if provided
useEffect(() => {
if (prefillClientId && clients.length > 0) {
const c = clients.find((c) => c.id === prefillClientId);
if (c) {
setSelectedClient(c);
setClientSearch(`${c.firstName} ${c.lastName}`);
}
}
}, [prefillClientId, clients]);
// Pre-fill invoice
useEffect(() => {
if (prefillInvoice) {
setSelectedInvoice(prefillInvoice);
setAmount(Number(prefillInvoice.balance));
}
}, [prefillInvoice]);
// Load unpaid invoices when client selected
useEffect(() => {
if (!selectedClient) { setInvoices([]); return; }
Promise.all([
api.get(`/invoices?clientId=${selectedClient.id}&status=sent`),
api.get(`/invoices?clientId=${selectedClient.id}&status=partial`),
api.get(`/invoices?clientId=${selectedClient.id}&status=overdue`),
]).then(([sent, partial, overdue]) => {
const sentList = sent.data.data.items || sent.data.data;
const partialList = partial.data.data.items || partial.data.data;
const overdueList = overdue.data.data.items || overdue.data.data;
setInvoices([...sentList, ...partialList, ...overdueList]);
}).catch(() => {});
}, [selectedClient]);
// Filter clients by search
const filteredClients = useMemo(() => {
if (!clientSearch || selectedClient) return [];
const q = clientSearch.toLowerCase();
return clients.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.accountNumber.toLowerCase().includes(q),
).slice(0, 8);
}, [clientSearch, clients, selectedClient]);
function selectClient(c: Client) {
setSelectedClient(c);
setClientSearch(`${c.firstName} ${c.lastName}`);
setShowClientDropdown(false);
setSelectedInvoice(null);
setAmount(0);
}
function clearClient() {
setSelectedClient(null);
setClientSearch('');
setSelectedInvoice(null);
setAmount(0);
setInvoices([]);
}
function selectInvoice(inv: Invoice) {
setSelectedInvoice(inv);
setAmount(Number(inv.balance));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedClient) { toast('Please select a client', 'error'); return; }
if (amount <= 0) { toast('Amount must be greater than 0', 'error'); return; }
setSubmitting(true);
try {
await api.post('/payments', {
clientId: selectedClient.id,
invoiceId: selectedInvoice?.id,
amount,
method,
referenceNo: referenceNo || undefined,
notes: notes || undefined,
});
toast('Payment recorded successfully', 'success');
onSuccess();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to record payment', 'error');
} finally {
setSubmitting(false);
}
}
const inputClass = 'block w-full rounded-lg border border-surface-200 dark:border-surface-700 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
return (
<FormModal open={open} onClose={onClose} title="Record Payment" description="Record a payment from a client" wide>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Client search */}
<div className="relative">
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Client</label>
{selectedClient ? (
<div className="flex items-center gap-3 px-3.5 py-2.5 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-700">
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 text-xs font-bold">
{selectedClient.firstName[0]}{selectedClient.lastName[0]}
</div>
<div className="flex-1">
<span className="text-sm font-medium text-surface-800 dark:text-surface-200">{selectedClient.firstName} {selectedClient.lastName}</span>
<span className="ml-2 text-xs font-mono text-surface-400">{selectedClient.accountNumber}</span>
</div>
<button type="button" onClick={clearClient} className="text-surface-400 hover:text-surface-600 dark:hover:text-surface-300 cursor-pointer" aria-label="Change client">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
</div>
) : (
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-400" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><circle cx="7" cy="7" r="5" /><path d="M11 11l3.5 3.5" /></svg>
<input
type="text"
value={clientSearch}
onChange={(e) => { setClientSearch(e.target.value); setShowClientDropdown(true); }}
onFocus={() => setShowClientDropdown(true)}
placeholder="Search by name or account #..."
aria-label="Search client"
className={`${inputClass} pl-9`}
/>
{showClientDropdown && filteredClients.length > 0 && (
<div className="absolute z-10 mt-1 w-full bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{filteredClients.map((c) => (
<button key={c.id} type="button" onClick={() => selectClient(c)}
className="w-full text-left px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 flex items-center gap-3 cursor-pointer transition-colors">
<div className="w-7 h-7 rounded-full bg-primary-50 flex items-center justify-center text-primary-600 text-xs font-bold">
{c.firstName[0]}{c.lastName[0]}
</div>
<div>
<span className="text-sm text-surface-800 dark:text-surface-300">{c.firstName} {c.lastName}</span>
<span className="ml-2 text-xs font-mono text-surface-400">{c.accountNumber}</span>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Invoice selection */}
{selectedClient && (
<div>
<label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
Select Invoice <span className="text-red-400 font-normal">*</span>
</label>
{invoices.length > 0 ? (
<div className="space-y-2 max-h-40 overflow-y-auto">
{invoices.map((inv) => (
<button key={inv.id} type="button" onClick={() => selectInvoice(inv)}
className={`w-full text-left px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer ${
selectedInvoice?.id === inv.id
? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/20 ring-2 ring-primary-500/20'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300 dark:hover:border-surface-600 hover:bg-surface-50 dark:hover:bg-surface-700'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-surface-700 dark:text-surface-300">{inv.number}</span>
<Badge label={inv.status} variant={statusBadgeVariant(inv.status)} />
</div>
<div className="text-right">
<span className="text-sm font-medium text-surface-900 dark:text-surface-200">PHP {Number(inv.balance).toLocaleString()}</span>
<span className="text-xs text-surface-400 ml-2">of {Number(inv.amount).toLocaleString()}</span>
</div>
</div>
<p className="mt-1 text-xs text-surface-400">Due: {new Date(inv.dueDate).toLocaleDateString()}</p>
</button>
))}
</div>
) : (
<div className="px-4 py-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700">
No unpaid invoices for this client. Generate an invoice first before recording payment.
</div>
)}
</div>
)}
{/* Amount + Method */}
{selectedClient && (
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="pay-amount" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
Amount (PHP)
{selectedInvoice && (
<span className="text-surface-400 font-normal ml-1">Balance: {Number(selectedInvoice.balance).toLocaleString()}</span>
)}
</label>
<input id="pay-amount" type="number" required min={0.01} step={0.01} value={amount || ''}
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
className={inputClass} placeholder="0.00" />
{selectedInvoice && amount > 0 && amount < Number(selectedInvoice.balance) && (
<p className="mt-1 text-xs text-amber-600">Partial payment remaining balance: PHP {(Number(selectedInvoice.balance) - amount).toLocaleString()}</p>
)}
</div>
<div>
<label htmlFor="pay-method" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Payment Method</label>
<select id="pay-method" value={method} onChange={(e) => setMethod(e.target.value)} className={inputClass}>
<option value="cash">Cash</option>
<option value="gcash">GCash</option>
<option value="maya">Maya</option>
<option value="bank_transfer">Bank Transfer</option>
</select>
</div>
</div>
)}
{/* Reference + Notes */}
{selectedClient && method !== 'cash' && (
<div>
<label htmlFor="pay-ref" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">Reference Number</label>
<input id="pay-ref" type="text" value={referenceNo} onChange={(e) => setReferenceNo(e.target.value)}
className={inputClass} placeholder="Transaction reference #" />
</div>
)}
{selectedClient && (
<div>
<label htmlFor="pay-notes" className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
Notes <span className="text-surface-400 font-normal">(optional)</span>
</label>
<input id="pay-notes" type="text" value={notes} onChange={(e) => setNotes(e.target.value)}
className={inputClass} placeholder="Additional notes" />
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!selectedClient || !selectedInvoice || amount <= 0}>
Record Payment
</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
const ADMIN_API_URL = process.env.NEXT_PUBLIC_ADMIN_API_URL || 'http://localhost:3004/api';
function formatBytes(bytes: number) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
interface SupportModalProps {
open: boolean;
onClose: () => void;
}
export function SupportModal({ open, onClose }: SupportModalProps) {
const { toast } = useToast();
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('general');
const [priority, setPriority] = useState('normal');
const [submitting, setSubmitting] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setSubject(''); setDescription(''); setCategory('general'); setPriority('normal'); setSelectedFiles([]);
}
}, [open]);
function authHeaders() {
const token = localStorage.getItem('accessToken');
return { Authorization: `Bearer ${token}` };
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!subject.trim() || !description.trim()) return;
setSubmitting(true);
try {
const res = await fetch(`${ADMIN_API_URL}/public/support/tickets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ subject, description, category, priority }),
});
const data = await res.json();
const ticketId = data.data?.id || data.id;
if (ticketId && selectedFiles.length > 0) {
const formData = new FormData();
selectedFiles.forEach((f) => formData.append('files', f));
await fetch(`${ADMIN_API_URL}/public/support/tickets/${ticketId}/attachments`, {
method: 'POST', headers: authHeaders(), body: formData,
});
}
toast('Ticket submitted', 'success');
onClose();
} catch { toast('Failed to create ticket', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="New Support Ticket" description="Describe your issue and we'll get back to you" wide>
<form onSubmit={handleSubmit} className="space-y-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Subject</label>
<input type="text" required value={subject} onChange={(e) => setSubject(e.target.value)} className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Description</label>
<textarea required rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className="w-full px-3 py-2 border border-surface-300 rounded-lg text-sm resize-none" /></div>
<div className="flex gap-4">
<div className="flex-1"><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Category</label>
<select value={category} onChange={(e) => setCategory(e.target.value)} className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="general">General</option><option value="billing">Billing</option><option value="technical">Technical</option><option value="account">Account</option><option value="feature_request">Feature Request</option>
</select></div>
<div className="flex-1"><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Priority</label>
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 bg-white dark:bg-surface-800 rounded-lg text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="low">Low</option><option value="normal">Normal</option><option value="high">High</option><option value="urgent">Urgent</option>
</select></div>
</div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">Attachments</label>
<div className="border border-dashed border-surface-300 dark:border-surface-600 rounded-lg p-3 text-center">
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.txt,.doc,.docx" className="hidden"
onChange={(e) => { if (e.target.files) { setSelectedFiles([...selectedFiles, ...Array.from(e.target.files!)].slice(0, 5)); e.target.value = ''; } }} />
<button type="button" onClick={() => fileInputRef.current?.click()} className="text-sm text-primary-600 hover:text-primary-700">Click to attach files</button>
<p className="text-xs text-surface-400 mt-1">Max 5 files, 10MB each</p>
</div>
{selectedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedFiles.map((f, i) => (
<div key={i} className="flex items-center gap-1.5 px-2 py-1 bg-surface-50 dark:bg-surface-700 border border-surface-200 dark:border-surface-600 rounded text-xs">
<span className="text-surface-700">{f.name}</span><span className="text-surface-400">({formatBytes(f.size)})</span>
<button type="button" onClick={() => setSelectedFiles(selectedFiles.filter((_, j) => j !== i))} className="text-surface-400 hover:text-red-500 ml-1">&times;</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting}>Submit Ticket</Button>
</div>
</form>
</FormModal>
);
}

View File

@@ -0,0 +1,246 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Badge, statusBadgeVariant } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
import { LocationPickerModal } from '@/components/maps/location-picker-modal';
const LeafletMap = dynamic(() => import('@/components/maps/leaflet-map').then((m) => ({ default: m.LeafletMap })), { ssr: false });
interface TicketDetailModalProps {
open: boolean;
onClose: () => void;
onUpdated: () => void;
ticketId: string | null;
}
export function TicketDetailModal({ open, onClose, onUpdated, ticketId }: TicketDetailModalProps) {
const { toast } = useToast();
const [ticket, setTicket] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState('');
const [priority, setPriority] = useState('');
const [comment, setComment] = useState('');
const [saving, setSaving] = useState(false);
const [resolving, setResolving] = useState(false);
const [selectedLat, setSelectedLat] = useState<number | null>(null);
const [selectedLng, setSelectedLng] = useState<number | null>(null);
const [showLocationPicker, setShowLocationPicker] = useState(false);
useEffect(() => {
if (!open || !ticketId) return;
setLoading(true);
api.get(`/tickets/${ticketId}`).then((r) => {
const t = r.data.data;
setTicket(t);
setStatus(t.status);
setPriority(t.priority);
if (t.client?.latitude != null && t.client?.longitude != null) {
setSelectedLat(t.client.latitude);
setSelectedLng(t.client.longitude);
}
}).catch(() => toast('Failed to load ticket', 'error'))
.finally(() => setLoading(false));
}, [open, ticketId, toast]);
async function handleUpdate() {
if (!ticket) return;
setSaving(true);
try {
const updates: any = {};
if (status !== ticket.status) updates.status = status;
if (priority !== ticket.priority) updates.priority = priority;
if (comment.trim()) {
const existingDesc = ticket.description || '';
const timestamp = new Date().toLocaleString();
const newDesc = existingDesc
? `${existingDesc}\n\n--- Comment (${timestamp}) ---\n${comment.trim()}`
: `--- Comment (${timestamp}) ---\n${comment.trim()}`;
updates.description = newDesc;
}
if (Object.keys(updates).length === 0) {
toast('No changes to save', 'info');
setSaving(false);
return;
}
await api.patch(`/tickets/${ticketId}`, updates);
toast('Ticket updated', 'success');
setComment('');
onUpdated();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to update', 'error');
} finally {
setSaving(false);
}
}
async function handleResolve() {
setResolving(true);
try {
const body: any = {};
if (selectedLat !== null && selectedLng !== null) {
body.latitude = selectedLat;
body.longitude = selectedLng;
}
await api.patch(`/tickets/${ticketId}/resolve`, body);
toast('Ticket resolved', 'success');
onUpdated();
onClose();
} catch (err: any) {
toast(err.response?.data?.error || 'Failed to resolve', 'error');
} finally {
setResolving(false);
}
}
const inputClass = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
if (!open) return null;
return (
<FormModal open={open} onClose={onClose} title={ticket?.title || 'Loading...'} wide>
{loading ? (
<div className="py-8 text-center text-surface-400">Loading ticket details...</div>
) : ticket ? (
<div className="space-y-5">
{/* Ticket info */}
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Type</span>
<div className="mt-1"><Badge label={ticket.type} /></div>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Status</span>
<div className="mt-1"><Badge label={ticket.status} variant={statusBadgeVariant(ticket.status)} /></div>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Client</span>
<p className="mt-1 text-sm text-surface-800 dark:text-surface-200">
{ticket.client ? `${ticket.client.firstName} ${ticket.client.lastName}` : '—'}
</p>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Assignee</span>
<p className="mt-1 text-sm text-surface-800 dark:text-surface-200">
{ticket.assignee ? `${ticket.assignee.firstName} ${ticket.assignee.lastName}` : 'Unassigned'}
</p>
</div>
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Created</span>
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{new Date(ticket.createdAt).toLocaleString()}</p>
</div>
{ticket.resolvedAt && (
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Resolved</span>
<p className="mt-1 text-sm text-surface-500 dark:text-surface-400">{new Date(ticket.resolvedAt).toLocaleString()}</p>
</div>
)}
</div>
{/* Notes/Comments history */}
{ticket.description && (
<div>
<span className="text-xs font-medium text-surface-400 dark:text-surface-500 uppercase">Notes & Comments</span>
<div className="mt-2 bg-surface-50 dark:bg-surface-800 rounded-lg p-4 text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap max-h-40 overflow-y-auto">
{ticket.description}
</div>
</div>
)}
{/* Update form — only if not resolved/cancelled */}
{ticket.status !== 'resolved' && ticket.status !== 'cancelled' && (
<>
<div className="border-t border-surface-200 pt-5">
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Update Ticket</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="t-status" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Status</label>
<select id="t-status" value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
</select>
</div>
<div>
<label htmlFor="t-priority" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Priority</label>
<select id="t-priority" value={priority} onChange={(e) => setPriority(e.target.value)} className={inputClass}>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
</div>
<div>
<label htmlFor="t-comment" className="block text-sm font-medium text-surface-700 dark:text-surface-300">Add Comment</label>
<textarea id="t-comment" rows={3} value={comment} onChange={(e) => setComment(e.target.value)}
className={`${inputClass} resize-none`} placeholder="Add a note or comment..." />
</div>
{/* Location picker for installation tickets */}
{ticket.type === 'installation' && ticket.clientId && (
<div className="border-t border-surface-200 pt-5">
<h3 className="text-sm font-semibold text-surface-800 dark:text-surface-200 mb-3">Client Location</h3>
{selectedLat !== null && selectedLng !== null ? (
<div className="mb-3">
<LeafletMap latitude={selectedLat} longitude={selectedLng} height="200px" zoom={16} />
</div>
) : null}
<div className="flex items-center justify-between">
<span className="text-sm text-surface-500">
{selectedLat !== null && selectedLng !== null
? `${selectedLat.toFixed(6)}, ${selectedLng.toFixed(6)}`
: 'No location pinned yet'}
</span>
<Button size="sm" variant="secondary" onClick={() => setShowLocationPicker(true)}>
{selectedLat !== null ? 'Update Pin' : 'Pin Location'}
</Button>
</div>
</div>
)}
<div className="flex justify-between pt-2">
<Button variant="secondary" onClick={handleResolve} loading={resolving}>
Resolve Ticket
</Button>
<div className="flex gap-3">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={handleUpdate} loading={saving}>Save Changes</Button>
</div>
</div>
</>
)}
{/* Already resolved */}
{(ticket.status === 'resolved' || ticket.status === 'cancelled') && (
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={onClose}>Close</Button>
</div>
)}
</div>
) : null}
<LocationPickerModal
open={showLocationPicker}
onClose={() => setShowLocationPicker(false)}
onConfirm={(lat, lng) => {
setSelectedLat(lat);
setSelectedLng(lng);
setShowLocationPicker(false);
}}
initialLatitude={selectedLat}
initialLongitude={selectedLng}
title="Pin Installation Location"
description="Pin the client's installation location on the map."
/>
</FormModal>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import { FormModal } from '@/components/ui/form-modal';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/toast';
interface TransferModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function TransferModal({ open, onClose, onSuccess }: TransferModalProps) {
const { toast } = useToast();
const [accounts, setAccounts] = useState<any[]>([]);
const [form, setForm] = useState({ fromAccountId: '', toAccountId: '', amount: 0, description: '' });
const [submitting, setSubmitting] = useState(false);
const ic = 'mt-1 block w-full rounded-lg border border-surface-200 dark:border-surface-600 bg-white dark:bg-surface-800 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:border-primary-500 dark:focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200';
useEffect(() => {
if (open) {
setForm({ fromAccountId: '', toAccountId: '', amount: 0, description: '' });
api.get('/accounts').then((r) => setAccounts(r.data.data)).catch(() => {});
}
}, [open]);
const fromAccount = accounts.find((a: any) => a.id === form.fromAccountId);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); setSubmitting(true);
try { await api.post('/accounts/transfer', { ...form, description: form.description || undefined }); toast('Transfer completed', 'success'); onSuccess(); onClose(); }
catch (err: any) { toast(err.response?.data?.error || 'Failed', 'error'); }
finally { setSubmitting(false); }
}
return (
<FormModal open={open} onClose={onClose} title="Transfer Funds" description="Move funds between company accounts. A journal entry will be created automatically.">
<form onSubmit={handleSubmit} className="space-y-4">
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">From Account</label>
<select required value={form.fromAccountId} onChange={(e) => setForm({ ...form, fromAccountId: e.target.value })} className={ic}>
<option value="">Select source...</option>
{accounts.map((a: any) => <option key={a.id} value={a.id}>{a.name} (PHP {Number(a.balance).toLocaleString()})</option>)}
</select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">To Account</label>
<select required value={form.toAccountId} onChange={(e) => setForm({ ...form, toAccountId: e.target.value })} className={ic}>
<option value="">Select destination...</option>
{accounts.filter((a: any) => a.id !== form.fromAccountId).map((a: any) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Amount (PHP) {fromAccount && <span className="text-surface-400 font-normal">Available: {Number(fromAccount.balance).toLocaleString()}</span>}</label>
<input type="number" required min={0.01} step={0.01} value={form.amount || ''} onChange={(e) => setForm({ ...form, amount: parseFloat(e.target.value) || 0 })} className={ic} /></div>
<div><label className="block text-sm font-medium text-surface-700 dark:text-surface-300">Description <span className="text-surface-400 font-normal">(optional)</span></label>
<input type="text" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className={ic} placeholder="e.g. Weekly GCash to bank transfer" /></div>
<div className="flex justify-end gap-3 pt-2"><Button type="button" variant="secondary" onClick={onClose}>Cancel</Button>
<Button type="submit" loading={submitting} disabled={!form.fromAccountId || !form.toAccountId || form.amount <= 0}>Transfer</Button></div>
</form>
</FormModal>
);
}