382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
} |