Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use client';
import React, { useState } from 'react';
import { KanbanBoard } from '@/components/dashboard/KanbanBoard';
import { InvoiceStatus } from '@/lib/invoiceStateMachine';

export default function DashboardPage() {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('kanban');
const [toastMessage, setToastMessage] = useState<string | null>(null);

const [invoices, setInvoices] = useState([
{ id: 'INV-001', recipientCount: 3, amount: '150 XLM', dueDate: '2026-09-01', status: 'Draft' as InvoiceStatus },
{ id: 'INV-002', recipientCount: 1, amount: '500 XLM', dueDate: '2026-08-28', status: 'Pending' as InvoiceStatus },
]);

const handleStatusChange = async (id: string, newStatus: InvoiceStatus) => {
const res = await fetch(`/api/invoices/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error('API update failed');
};

const showToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 4000);
};

return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<div className="flex gap-2">
<button onClick={() => setViewMode('list')} className={`px-4 py-2 rounded ${viewMode === 'list' ? 'bg-black text-white' : 'bg-gray-200'}`}>List View</button>
<button onClick={() => setViewMode('kanban')} className={`px-4 py-2 rounded ${viewMode === 'kanban' ? 'bg-black text-white' : 'bg-gray-200'}`}>Kanban Board</button>
</div>
</div>

{toastMessage && (
<div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded shadow">
{toastMessage}
</div>
)}

{viewMode === 'kanban' ? (
<KanbanBoard invoices={invoices} onStatusChange={handleStatusChange} onToast={showToast} />
) : (
<div>Standard List/Table View Component Content...</div>
)}
</div>
);
}
87 changes: 87 additions & 0 deletions components/dashboard/KanbanBoard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'tsx' // placeholder for syntax hint
import React, { useState } from 'react';
import { DndContext, DragEndEvent, closestCenter } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { InvoiceStatus, statusTransitionGuard } from '@/lib/invoiceStateMachine';

interface InvoiceCardData {
id: string;
recipientCount: number;
amount: string;
dueDate: string;
status: InvoiceStatus;
}

interface KanbanBoardProps {
invoices: InvoiceCardData[];
onStatusChange: (id: string, newStatus: InvoiceStatus) => Promise<void>;
onToast: (msg: string) => void;
}

const COLUMNS: InvoiceStatus[] = ['Draft', 'Pending', 'Partially Paid', 'Fully Paid', 'Disputed'];

function SortableItem({ invoice }: { invoice: InvoiceCardData }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: invoice.id });
const style = { transform: CSS.Transform.toString(transform), transition };

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} tabIndex={0} className="bg-white p-4 rounded-lg shadow mb-3 cursor-grab focus:outline-none focus:ring-2 focus:ring-indigo-500">
<div className="font-semibold text-gray-900">ID: {invoice.id}</div>
<div className="text-sm text-gray-600">Recipients: {invoice.recipientCount}</div>
<div className="text-sm font-medium text-gray-800 mt-1">{invoice.amount}</div>
<span className="inline-block mt-2 px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded-full">Due: {invoice.dueDate}</span>
</div>
);
}

export function KanbanBoard({ invoices, onStatusChange, onToast }: KanbanBoardProps) {
const [items, setItems] = useState(invoices);

const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;

const activeId = active.id as string;
const targetStatus = over.id as InvoiceStatus;

const card = items.find((i) => i.id === activeId);
if (!card) return;

if (!statusTransitionGuard(card.status, targetStatus)) {
onToast(`Invalid transition from ${card.status} to ${targetStatus}`);
return;
}

const previousStatus = card.status;
// Optimistic update
setItems((prev) => prev.map((i) => (i.id === activeId ? { ...i, status: targetStatus } : i)));

try {
await onStatusChange(activeId, targetStatus);
} catch {
// Rollback on failure
setItems((prev) => prev.map((i) => (i.id === activeId ? { ...i, status: previousStatus } : i)));
onToast('Failed to update status on server. Rolled back.');
}
};

return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="flex gap-4 overflow-x-auto pb-4 w-full">
{COLUMNS.map((col) => (
<div key={col} id={col} className="bg-gray-50 rounded-xl p-4 w-80 flex-shrink-0 flex flex-col max-h-[75vh]">
<h3 className="font-bold text-gray-700 mb-3 sticky top-0 bg-gray-50 py-1">{col}</h3>
<div className="overflow-y-auto flex-1 pr-1">
<SortableContext items={items.filter((i) => i.status === col).map((i) => i.id)} strategy={verticalListSortingStrategy}>
{items.filter((i) => i.status === col).map((inv) => (
<SortableItem key={inv.id} invoice={inv} />
))}
</SortableContext>
</div>
</div>
))}
</div>
</DndContext>
);
}
16 changes: 16 additions & 0 deletions lib/__tests__/invoiceStateMachine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { statusTransitionGuard, InvoiceStatus } from '../invoiceStateMachine';

describe('invoiceStateMachine', () => {
const statuses: InvoiceStatus[] = ['Draft', 'Pending', 'Partially Paid', 'Fully Paid', 'Disputed'];

test('allows valid transitions and self-transitions', () => {
expect(statusTransitionGuard('Draft', 'Pending')).toBe(true);
expect(statusTransitionGuard('Pending', 'Fully Paid')).toBe(true);
expect(statusTransitionGuard('Draft', 'Draft')).toBe(true);
});

test('rejects invalid transitions', () => {
expect(statusTransitionGuard('Draft', 'Fully Paid')).toBe(false);
expect(statusIntsGuard => statusTransitionGuard('Fully Paid', 'Pending')).toBe(false);
});
});
15 changes: 15 additions & 0 deletions lib/invoiceStateMachine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { isValidTransition } from './invoiceStateMachine';

describe('Invoice State Machine', () => {
it('allows valid transitions', () => {
expect(isValidTransition('Draft', 'Pending')).toBe(true);
expect(isValidTransition('Pending', 'Fully Paid')).toBe(true);
expect(isValidTransition('Partially Paid', 'Fully Paid')).toBe(true);
});

it('rejects invalid transitions', () => {
expect(isValidTransition('Draft', 'Fully Paid')).toBe(false);
expect(isValidTransition('Fully Paid', 'Draft')).toBe(false);
});
});
14 changes: 14 additions & 0 deletions lib/invoiceStateMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type InvoiceStatus = 'Draft' | 'Pending' | 'Partially Paid' | 'Fully Paid' | 'Disputed';

const ALLOWED_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
Draft: ['Pending', 'Disputed'],
Pending: ['Partially Paid', 'Fully Paid', 'Disputed'],
'Partially Paid': ['Fully Paid', 'Disputed'],
'Fully Paid': [],
Disputed: ['Pending', 'Fully Paid']
};

export function statusTransitionGuard(current: InvoiceStatus, target: InvoiceStatus): boolean {
if (current === target) return true;
return ALLOWED_TRANSITIONS[current]?.includes(target) ?? false;
}
Loading