first commit
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
Skeleton,
|
||||
} from "ikoncomponents";
|
||||
import MeetingDetailsForm from "./components/meetingForm/MeetingDetailsForm";
|
||||
|
||||
import { CalendarDays, MapPin, Clock, Users, ListChecks, Zap, Search } from "lucide-react";
|
||||
import { createMeetingApi, meetingsApi, updateMeetingApi } from "@/app/utils/api/meetingApi";
|
||||
|
||||
interface MeetingResponse {
|
||||
id: string;
|
||||
meeting: {
|
||||
title: string;
|
||||
projectName: string;
|
||||
place: string;
|
||||
date: string;
|
||||
time: string;
|
||||
duration: string;
|
||||
calledBy: string;
|
||||
};
|
||||
attendees: {
|
||||
attendees: { name: string; role: string; contact: string }[];
|
||||
};
|
||||
agenda: {
|
||||
agenda: { item: string; owner: string }[];
|
||||
};
|
||||
decisions: {
|
||||
decisions: string[];
|
||||
};
|
||||
actions: {
|
||||
actions: { item: string; responsible: string; dueDate: string }[];
|
||||
};
|
||||
others: {
|
||||
notes: string;
|
||||
};
|
||||
projectIdentifier: string;
|
||||
status: "Draft" | "Completed";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MomTabProps {
|
||||
projectIdentifier: string;
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
export default function MomTab({ projectIdentifier, projectName = "" }: MomTabProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editMeeting, setEditMeeting] = useState<MeetingResponse | null>(null);
|
||||
const [meetings, setMeetings] = useState<MeetingResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeStatus, setActiveStatus] = useState<"Draft" | "Completed">("Draft");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await meetingsApi(projectIdentifier);
|
||||
if (response && Array.isArray(response)) {
|
||||
setMeetings(response);
|
||||
} else {
|
||||
setMeetings([]);
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load meetings.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (data): Promise<void> => {
|
||||
if (editMeeting) {
|
||||
await updateMeetingApi(editMeeting.id, data);
|
||||
} else {
|
||||
await createMeetingApi(data);
|
||||
}
|
||||
setOpen(false);
|
||||
setEditMeeting(null);
|
||||
fetchMeetings();
|
||||
};
|
||||
|
||||
const handleEditClick = (meeting: MeetingResponse) => {
|
||||
// Always create a new object so initialData reference changes
|
||||
// even if the user clicks the same meeting twice
|
||||
setEditMeeting({ ...meeting });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMeetings();
|
||||
}, [projectIdentifier]);
|
||||
|
||||
const filtered = meetings
|
||||
.filter((m) => m.status === activeStatus)
|
||||
.filter((m) =>
|
||||
search.trim() === "" ||
|
||||
m.meeting.title?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.meeting.place?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||
};
|
||||
|
||||
const formatTime = (timeStr: string) => {
|
||||
if (!timeStr || !timeStr.includes(":")) return "—";
|
||||
const [h, m] = timeStr.split(":");
|
||||
const hour = parseInt(h, 10);
|
||||
const ampm = hour >= 12 ? "PM" : "AM";
|
||||
const displayHour = hour % 12 || 12;
|
||||
return `${displayHour}:${m} ${ampm}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header Row — toggle + search + button all in one line */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Draft / Completed Toggle */}
|
||||
<div className="flex items-center bg-muted rounded-lg p-1 gap-1 shrink-0">
|
||||
<Button
|
||||
variant={activeStatus === "Draft" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveStatus("Draft")}
|
||||
className="rounded-md px-4"
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeStatus === "Completed" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveStatus("Completed")}
|
||||
className="rounded-md px-4"
|
||||
>
|
||||
Completed
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search meetings..."
|
||||
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New Meeting */}
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => { setEditMeeting(null); setOpen(true); }}
|
||||
>
|
||||
+ New Meeting
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<CardHeader className="space-y-2 pb-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<CalendarDays className="w-12 h-12 text-muted-foreground opacity-40" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{search ? "No meetings match your search" : `No ${activeStatus} meetings found`}
|
||||
</p>
|
||||
{!search && (
|
||||
<Button variant="outline" size="sm" onClick={() => { setEditMeeting(null); setOpen(true); }}>
|
||||
Create one
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
|
||||
{filtered.map((m) => (
|
||||
<MeetingCard
|
||||
key={m.id}
|
||||
meeting={m}
|
||||
formatDate={formatDate}
|
||||
formatTime={formatTime}
|
||||
onEdit={handleEditClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MeetingDetailsForm
|
||||
open={open}
|
||||
setOpen={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) setEditMeeting(null);
|
||||
}}
|
||||
projectIdentifier={projectIdentifier}
|
||||
projectName={projectName}
|
||||
initialData={editMeeting}
|
||||
onSubmit={(data) => handleSave(data)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Meeting Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MeetingCardProps {
|
||||
meeting: MeetingResponse;
|
||||
formatDate: (d: string) => string;
|
||||
formatTime: (t: string) => string;
|
||||
onEdit: (meeting: MeetingResponse) => void;
|
||||
}
|
||||
|
||||
function MeetingCard({ meeting, formatDate, formatTime, onEdit }: MeetingCardProps) {
|
||||
const { meeting: m, attendees, agenda, decisions, actions, status } = meeting;
|
||||
const isCompleted = status === "Completed";
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden hover:shadow-md transition-shadow duration-200">
|
||||
{/* Accent bar */}
|
||||
<div className={`h-1 w-full ${isCompleted ? "bg-emerald-400" : "bg-amber-400"}`} />
|
||||
|
||||
<CardHeader className="pb-1 pt-2 px-3">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<CardTitle className="text-[15px] font-semibold line-clamp-1 flex-1">
|
||||
{m.title}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[13px] px-1 py-0 shrink-0 ${
|
||||
isCompleted
|
||||
? "bg-emerald-50 text-emerald-600 border-emerald-200"
|
||||
: "bg-amber-50 text-amber-600 border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Meta — single line */}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground flex-wrap pt-0.5">
|
||||
<CalendarDays className="w-2.5 h-2.5 shrink-0" />
|
||||
<span>{formatDate(m.date)}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<Clock className="w-2.5 h-2.5 shrink-0" />
|
||||
<span>{formatTime(m.time)}</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span>{m.duration} min</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<MapPin className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="line-clamp-1">{m.place}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-1 flex-1 pt-0 px-3 pb-2">
|
||||
<Separator />
|
||||
|
||||
{/* Stats row — compact */}
|
||||
<div className="grid grid-cols-3 gap-1 text-center">
|
||||
<StatPill
|
||||
icon={<Users className="w-2.5 h-2.5 mx-auto mb-0.5 text-muted-foreground" />}
|
||||
label="Attendees"
|
||||
value={attendees.attendees.length}
|
||||
/>
|
||||
<StatPill
|
||||
icon={<ListChecks className="w-2.5 h-2.5 mx-auto mb-0.5 text-muted-foreground" />}
|
||||
label="Agenda"
|
||||
value={agenda.agenda.length}
|
||||
/>
|
||||
<StatPill
|
||||
icon={<Zap className="w-2.5 h-2.5 mx-auto mb-0.5 text-muted-foreground" />}
|
||||
label="Actions"
|
||||
value={actions.actions.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Completed → Key Decisions */}
|
||||
{isCompleted && decisions.decisions.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Key Decisions
|
||||
</p>
|
||||
<li className="flex items-start gap-1 text-[10px] text-foreground list-none">
|
||||
<span className="mt-1 w-1 h-1 rounded-full bg-emerald-400 shrink-0" />
|
||||
<span className="line-clamp-1">{decisions.decisions[0]}</span>
|
||||
</li>
|
||||
{decisions.decisions.length > 1 && (
|
||||
<p className="text-[9px] text-muted-foreground pl-2">
|
||||
+{decisions.decisions.length - 1} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draft → Agenda Preview */}
|
||||
{!isCompleted && agenda.agenda.length > 0 && (
|
||||
<div className="space-y-1 ">
|
||||
<p className="text-[9px] mt-1 font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Agenda
|
||||
</p>
|
||||
<li className="flex items-start gap-1 text-[11px] text-foreground list-none">
|
||||
<span className="mt-1 w-1 h-1 rounded-full bg-amber-400 shrink-0" />
|
||||
<span className="line-clamp-1">{agenda.agenda[0].item}</span>
|
||||
</li>
|
||||
{agenda.agenda.length > 1 && (
|
||||
<p className="text-[10px] text-muted-foreground pl-2">
|
||||
+{agenda.agenda.length - 1} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="border-t bg-muted/40 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{formatDate(meeting.createdAt)}
|
||||
</span>
|
||||
{!isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 text-[12px] px-1.5 border p-3"
|
||||
onClick={() => onEdit(meeting)}
|
||||
>
|
||||
Continue Editing →
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Pill ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatPill({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted rounded-md py-1.5 px-1 flex flex-col items-center">
|
||||
{icon}
|
||||
<p className="text-xs font-semibold text-foreground">{value}</p>
|
||||
<p className="text-[9px] text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user