first commit

This commit is contained in:
Your NamebaishaliHolocron
2026-06-15 12:57:03 +05:30
commit b9ac5ae0b2
398 changed files with 49583 additions and 0 deletions

View File

@@ -0,0 +1,305 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Dialog, DialogContent } from "ikoncomponents";
import StepIndicator from "./components/StepIndicator";
import MeetingStep from "./components/steps/MeetingStep";
import AttendeesStep from "./components/steps/AttendeesStep";
import AgendaStep from "./components/steps/AgendaStep";
import DecisionsStep from "./components/steps/DecisionsStep";
import ActionsStep from "./components/steps/ActionsStep";
import OthersStep from "./components/steps/OthersStep";
import { STEPS, cn, getInitialFormData } from "./lib/utils";
import { stepKeys } from "./lib/constants";
import { validateStep, stepHasErrors, StepErrors } from "./lib/validation";
import { FormData, StepKey, ApiUser } from "./types";
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 MeetingDetailsFormProps {
open: boolean;
setOpen: (open: boolean) => void;
onSubmit: (data: FormData & { projectIdentifier: string }) => Promise<void>;
projectIdentifier: string;
projectName: string;
initialData?: MeetingResponse | null;
}
export default function MeetingDetailsForm({
open,
setOpen,
onSubmit,
projectIdentifier,
projectName,
initialData,
}: MeetingDetailsFormProps) {
const [currentStep, setCurrentStep] = useState<number>(1);
const [saveStatus, setSaveStatus] = useState<"idle" | "draft" | "success" | "error">("idle");
const [users, setUsers] = useState<ApiUser[]>([]);
const [stepErrors, setStepErrors] = useState<Record<number, StepErrors>>({});
// ─── Build form data from initialData (edit) or blank (new) ───────────────
const buildFormData = (): FormData => {
if (initialData) {
return {
meeting: {
title: initialData.meeting.title ?? "",
projectName: initialData.meeting.projectName ?? projectName,
place: initialData.meeting.place ?? "",
date: initialData.meeting.date ?? "",
time: initialData.meeting.time ?? "",
duration: initialData.meeting.duration ?? "",
durationUnit: "minutes",
calledBy: initialData.meeting.calledBy ?? "",
externalCalledBy: "",
},
attendees: initialData.attendees ?? { attendees: [] },
agenda: initialData.agenda ?? { agenda: [] },
decisions: initialData.decisions ?? { decisions: [] },
actions: initialData.actions ?? { actions: [] },
others: initialData.others ?? { notes: "" },
};
}
return getInitialFormData(projectName);
};
const [formData, setFormData] = useState<FormData>(buildFormData);
// Re-populate whenever initialData changes (switching between drafts or new)
useEffect(() => {
setFormData(buildFormData());
setCurrentStep(1);
setStepErrors({});
setSaveStatus("idle");
}, [initialData, open]);
// ─── Helpers ──────────────────────────────────────────────────────────────
const getStepData = (step: number) => {
const key = stepKeys[step - 1] as StepKey;
return formData[key];
};
const runValidation = (step: number): StepErrors => {
const errors = validateStep(step, getStepData(step));
setStepErrors((prev) => ({ ...prev, [step]: errors }));
return errors;
};
const errorsForStep = (step: number): StepErrors => stepErrors[step] ?? {};
const errorSteps = Object.entries(stepErrors)
.filter(([, errs]) => Object.keys(errs).length > 0)
.map(([s]) => Number(s));
// ─── Data update ──────────────────────────────────────────────────────────
const updateStepData = (step: number, field: string, value: unknown) => {
const key = stepKeys[step - 1] as StepKey;
setFormData((prev) => {
const updated = { ...prev, [key]: { ...prev[key], [field]: value } };
if (stepErrors[step]) {
const errors = validateStep(step, updated[key]);
setStepErrors((e) => ({ ...e, [step]: errors }));
}
return updated;
});
};
// ─── Build payload ────────────────────────────────────────────────────────
const buildPayload = (
allValid: boolean
): FormData & { projectIdentifier: string; status: "Completed" | "Draft" } => {
const { meeting, attendees, agenda, decisions, actions, others } = formData;
const meetingPayload = Object.fromEntries(
Object.entries(meeting).filter(
([key]) => key !== "durationUnit" && key !== "externalCalledBy"
)
) as typeof meeting;
return {
meeting: meetingPayload,
attendees: {
attendees: attendees.attendees.filter(
(a) => a.name?.trim() || a.role?.trim() || a.contact?.trim()
),
},
agenda: {
agenda: agenda.agenda.filter((a) => a.item?.trim() || a.owner?.trim()),
},
decisions: {
decisions: decisions.decisions.filter((d) => d?.trim()),
},
actions: {
actions: actions.actions.filter(
(a) => a.item?.trim() || a.responsible?.trim() || a.dueDate?.trim()
),
},
others: { notes: others.notes?.trim() ?? "" },
projectIdentifier,
status: allValid ? "Completed" : "Draft",
};
};
// ─── Navigation ───────────────────────────────────────────────────────────
const handleNext = () => {
const errors = runValidation(currentStep);
if (Object.keys(errors).length > 0) return;
setCurrentStep((s) => s + 1);
};
const handlePrev = () => setCurrentStep((s) => s - 1);
// ─── Save ─────────────────────────────────────────────────────────────────
const handleSave = async () => {
const allValid = Array.from({ length: 6 }, (_, i) => i + 1).every(
(step) => Object.keys(validateStep(step, getStepData(step))).length === 0
);
try {
await onSubmit(buildPayload(allValid));
setSaveStatus(allValid ? "success" : "draft");
} catch (err) {
console.error("Failed to save meeting:", err);
setSaveStatus("error");
}
};
// ─── Close ────────────────────────────────────────────────────────────────
const handleClose = () => {
setOpen(false);
setSaveStatus("idle");
setCurrentStep(1);
setStepErrors({});
};
// ─── Step renderer ────────────────────────────────────────────────────────
const renderStep = () => {
switch (currentStep) {
case 1:
return (
<MeetingStep
data={formData.meeting}
onChange={(f, v) => updateStepData(1, f, v)}
errors={errorsForStep(1)}
/>
);
case 2:
return (
<AttendeesStep
data={formData.attendees}
onChange={(f, v) => updateStepData(2, f, v)}
onUsersLoaded={setUsers}
errors={errorsForStep(2)}
/>
);
case 3:
return (
<AgendaStep
data={formData.agenda}
onChange={(f, v) => updateStepData(3, f, v)}
attendees={formData.attendees.attendees}
users={users}
errors={errorsForStep(3)}
/>
);
case 4:
return (
<DecisionsStep
data={formData.decisions}
onChange={(f, v) => updateStepData(4, f, v)}
errors={errorsForStep(4)}
/>
);
case 5:
return (
<ActionsStep
data={formData.actions}
onChange={(f, v) => updateStepData(5, f, v)}
errors={errorsForStep(5)}
/>
);
case 6:
return (
<OthersStep
data={formData.others}
onChange={(f, v) => updateStepData(6, f, v)}
errors={errorsForStep(6)}
/>
);
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-5xl! w-full p-0 h-4/5 max-h-xl">
<Card className="overflow-hidden border-0 shadow-none">
{/* Header */}
<div className="px-6 pt-5 pb-4 border-b border-slate-100">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">
{initialData ? "Continue Editing" : "Meeting Details"}
</h2>
</div>
<StepIndicator currentStep={currentStep} errors={errorSteps} />
</div>
{/* Body */}
<div className="px-6 py-5 max-h-2xl! h-full overflow-auto">
{saveStatus === "success" && (
<div className="mb-4 p-3 rounded-lg bg-emerald-50 border border-emerald-200 text-emerald-700 text-sm font-medium">
Meeting details saved successfully!
</div>
)}
{saveStatus === "error" && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm font-medium">
Failed to save meeting. Check the console for details and try again.
</div>
)}
{renderStep()}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-100 flex items-center justify-between">
<div>
{currentStep > 1 && (
<Button variant="outline" onClick={handlePrev}>
Prev
</Button>
)}
</div>
<div className="flex items-center gap-3">
{currentStep < 6 && (
<Button variant="outline" onClick={handleNext}>
Next
</Button>
)}
<Button onClick={handleSave}>Save</Button>
</div>
</div>
</Card>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,44 @@
import React from "react";
import { cn, STEPS } from "../lib/utils";
interface StepIndicatorProps {
currentStep: number;
errors: number[];
}
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, errors }) => (
<div className="flex items-center gap-1 flex-wrap">
{STEPS.map((step, idx) => {
const isActive = step.id === currentStep;
const isCompleted = step.id < currentStep;
const hasError = errors.includes(step.id);
return (
<div key={step.id} className="flex items-center">
<div className="flex items-center gap-1.5">
<div
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold transition-all",
isActive && "bg-slate-800 text-white",
isCompleted && !hasError && "bg-emerald-500 text-white",
hasError && "bg-destructive text-destructive-foreground",
!isActive && !isCompleted && !hasError &&
"bg-muted text-muted-foreground border border-border"
)}
>
{hasError ? "!" : isCompleted && !hasError ? "✓" : step.id}
</div>
<span className={cn("text-xs font-medium", isActive ? "text-foreground" : "text-muted-foreground")}>
{step.label}
</span>
</div>
{idx < STEPS.length - 1 && (
<div className={cn("h-px w-6 mx-1", isCompleted && !hasError ? "bg-emerald-400" : "bg-border")} />
)}
</div>
);
})}
</div>
);
export default StepIndicator;

View File

@@ -0,0 +1,134 @@
"use client";
import React from "react";
import { Input, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { ActionItem, ActionsData } from "../../types";
import { StepErrors } from "../../lib/validation";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface ActionsStepProps {
data: ActionsData;
onChange: (field: keyof ActionsData, value: ActionItem[]) => void;
errors: StepErrors;
}
const rowError = (errors: StepErrors, idx: number, field: string): string | undefined =>
errors[`actions.${idx}.${field}`];
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const ActionsStep: React.FC<ActionsStepProps> = ({ data, onChange, errors }) => {
// activeUsers is already filtered to active, non-deleted users by the cache.
const { activeUsers } = useAppCache();
const addAction = () =>
onChange("actions", [...data.actions, { item: "", responsible: "", dueDate: "" }]);
const updateAction = (idx: number, field: keyof ActionItem, val: string) => {
const updated = data.actions.map((a, i) => (i === idx ? { ...a, [field]: val } : a));
onChange("actions", updated);
};
const removeAction = (idx: number) => {
onChange("actions", data.actions.filter((_, i) => i !== idx));
};
return (
<div className="space-y-3">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[48px]">SI No</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8">
Item <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8">
Responsible <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[150px]">
Due Date <span className="text-destructive">*</span>
</th>
<th className="pb-2 w-[40px] text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={addAction}
className="text-emerald-600 border-emerald-500 hover:bg-emerald-50 text-xs px-2 py-1"
>
+ ADD
</Button>
</th>
</tr>
</thead>
<tbody>
{data.actions.map((action, idx) => (
<tr key={idx} className="align-top">
<td className="pr-3 pb-2 text-sm text-muted-foreground font-medium pt-2">{idx + 1}</td>
{/* Item */}
<td className="pr-8 pb-2">
<Input
value={action.item}
onChange={(e) => updateAction(idx, "item", e.target.value)}
placeholder="Action item"
className={`h-9 ${rowError(errors, idx, "item") ? "border-destructive" : ""}`}
/>
<FieldError message={rowError(errors, idx, "item")} />
</td>
{/* Responsible */}
<td className="pr-8 pb-2">
<Select
value={action.responsible}
onValueChange={(v) => updateAction(idx, "responsible", v)}
>
<SelectTrigger className={`h-9 ${rowError(errors, idx, "responsible") ? "border-destructive" : ""}`}>
<SelectValue placeholder="Select Responsible" />
</SelectTrigger>
<SelectContent side="bottom" position="popper" sideOffset={4} className="max-h-56 overflow-y-auto">
{activeUsers.map((user) => (
<SelectItem key={user.userId} value={user.userId}>
{user.userName}
{user.userDesignation ? ` (${user.userDesignation})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "responsible")} />
</td>
{/* Due Date */}
<td className="pr-3 pb-2">
<Input
type="date"
value={action.dueDate}
onChange={(e) => updateAction(idx, "dueDate", e.target.value)}
className={`h-9 ${rowError(errors, idx, "dueDate") ? "border-destructive" : ""}`}
/>
<FieldError message={rowError(errors, idx, "dueDate")} />
</td>
{/* Remove */}
<td className="pb-2 text-right">
{data.actions.length > 1 && (
<button
type="button"
onClick={() => removeAction(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-1"
>
×
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ActionsStep;

View File

@@ -0,0 +1,129 @@
"use client";
import React from "react";
import { Input, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { AgendaData, AgendaItem, Attendee, ApiUser } from "../../types";
import { StepErrors } from "../../lib/validation";
interface AgendaStepProps {
data: AgendaData;
onChange: (field: keyof AgendaData, value: AgendaItem[]) => void;
attendees: Attendee[];
users: ApiUser[];
errors: StepErrors;
}
const rowError = (errors: StepErrors, idx: number, field: string): string | undefined =>
errors[`agenda.${idx}.${field}`];
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const AgendaStep: React.FC<AgendaStepProps> = ({ data, onChange, attendees, users, errors }) => {
const addItem = () => onChange("agenda", [...data.agenda, { item: "", owner: "" }]);
const updateItem = (idx: number, field: keyof AgendaItem, val: string) => {
const updated = data.agenda.map((a, i) => (i === idx ? { ...a, [field]: val } : a));
onChange("agenda", updated);
};
const removeItem = (idx: number) => {
onChange("agenda", data.agenda.filter((_, i) => i !== idx));
};
const presenterOptions = attendees
.filter((a) => a.name)
.map((a) => {
const user = users.find((u) => u.userId === a.name);
return { value: a.name, label: user?.userName ?? a.name };
});
return (
<div className="space-y-3">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[48px]">SI No</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8">
Item <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3">
Owner / Presenter <span className="text-destructive">*</span>
</th>
<th className="pb-2 w-[40px] text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={addItem}
className="text-emerald-600 text-xs px-2 py-1"
>
+ ADD
</Button>
</th>
</tr>
</thead>
<tbody>
{data.agenda.map((ag, idx) => (
<tr key={idx} className="align-top">
<td className="pr-3 pb-2 text-sm text-muted-foreground font-medium pt-2">{idx + 1}</td>
<td className="pr-8 pb-2">
<Input
value={ag.item}
onChange={(e) => updateItem(idx, "item", e.target.value)}
placeholder="Agenda item"
className={rowError(errors, idx, "item") ? "border-destructive" : ""}
/>
<FieldError message={rowError(errors, idx, "item")} />
</td>
<td className="pr-3 pb-2">
<Select
value={ag.owner}
onValueChange={(v) => updateItem(idx, "owner", v)}
disabled={presenterOptions.length === 0}
>
<SelectTrigger className={rowError(errors, idx, "owner") ? "border-destructive" : ""}>
<SelectValue
placeholder={
presenterOptions.length === 0 ? "No attendees selected yet" : "Select Presenter Name"
}
/>
</SelectTrigger>
<SelectContent side="bottom" position="popper" sideOffset={4} className="max-h-52 overflow-y-auto">
{presenterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "owner")} />
</td>
<td className="pb-2 text-right">
{data.agenda.length > 1 && (
<button
type="button"
onClick={() => removeItem(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-1"
>
×
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{presenterOptions.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-1">
Go to the Attendees step and select attendees first to populate the Owner/Presenter dropdown.
</p>
)}
</div>
);
};
export default AgendaStep;

View File

@@ -0,0 +1,152 @@
"use client";
import React, { useEffect } from "react";
import { Input, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { ROLE_OPTIONS } from "../../lib/utils";
import { Attendee, AttendeesData, ApiUser } from "../../types";
import { StepErrors } from "../../lib/validation";
import { useAppCache } from "@/app/utils/context/AppCacheContext";
interface AttendeesStepProps {
data: AttendeesData;
onChange: (field: keyof AttendeesData, value: Attendee[]) => void;
onUsersLoaded: (users: ApiUser[]) => void;
errors: StepErrors;
}
// e.g. errors["attendees.0.name"] or errors["attendees.0.role"]
const rowError = (errors: StepErrors, idx: number, field: string): string | undefined =>
errors[`attendees.${idx}.${field}`];
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const AttendeesStep: React.FC<AttendeesStepProps> = ({ data, onChange, onUsersLoaded, errors }) => {
const { activeUsers } = useAppCache();
useEffect(() => {
// activeUsers is already filtered to active, non-deleted users by the cache.
const apiUsers: ApiUser[] = activeUsers.map((u) => ({ ...u, userPhone: u.userPhone ?? "" }));
onUsersLoaded(apiUsers);
}, [activeUsers]);
const addAttendee = () =>
onChange("attendees", [...data.attendees, { name: "", role: "", contact: "" }]);
const updateAttendee = (idx: number, field: keyof Attendee, val: string) => {
const updated = data.attendees.map((a, i) => (i === idx ? { ...a, [field]: val } : a));
onChange("attendees", updated);
};
const removeAttendee = (idx: number) => {
onChange("attendees", data.attendees.filter((_, i) => i !== idx));
};
return (
<div className="space-y-3">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-8 w-[35%]">
Name <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[30%]">
Dept./Role <span className="text-destructive">*</span>
</th>
<th className="text-left text-sm font-medium text-slate-700 pb-2 pr-3 w-[30%]">
Contact
</th>
<th className="pb-2 w-[40px] text-right">
<Button
type="button"
variant="outline"
size="sm"
onClick={addAttendee}
className="text-emerald-600 border-emerald-500 hover:bg-emerald-50 text-xs px-2 py-1"
>
+ ADD
</Button>
</th>
</tr>
</thead>
<tbody>
{data.attendees.map((att, idx) => (
<tr key={idx} className="align-top">
{/* Attendee Name */}
<td className="pr-8 pb-2">
<Select
value={att.name || ""}
onValueChange={(v) => updateAttendee(idx, "name", v)}
>
<SelectTrigger className={`w-full h-9 ${rowError(errors, idx, "name") ? "border-destructive" : ""}`}>
<SelectValue
placeholder={
activeUsers.length === 0 ? "No users found" : "Select Attendee Name"
}
/>
</SelectTrigger>
<SelectContent side="bottom" position="popper" className="w-full max-h-60 overflow-y-auto">
{activeUsers.length === 0 && (
<div className="px-2 py-1 text-sm text-muted-foreground">No active users available</div>
)}
{activeUsers.map((user) => (
<SelectItem key={user.userId} value={String(user.userId)} className="truncate">
{user.userName}
{user.userDesignation ? ` (${user.userDesignation})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "name")} />
</td>
{/* Role */}
<td className="pr-3 pb-2">
<Select
value={att.role}
onValueChange={(v) => updateAttendee(idx, "role", v)}
>
<SelectTrigger className={`h-9 ${rowError(errors, idx, "role") ? "border-destructive" : ""}`}>
<SelectValue placeholder="Select Attendee Role" />
</SelectTrigger>
<SelectContent side="bottom" position="popper" className="max-h-60 overflow-y-auto">
{ROLE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={rowError(errors, idx, "role")} />
</td>
{/* Contact */}
<td className="pr-3 pb-2">
<Input
className="h-9"
value={att.contact}
onChange={(e) => updateAttendee(idx, "contact", e.target.value)}
placeholder="Contact"
/>
</td>
{/* Remove */}
<td className="pb-2 text-right">
{data.attendees.length > 1 && (
<button
type="button"
onClick={() => removeAttendee(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-1"
>
×
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default AttendeesStep;

View File

@@ -0,0 +1,85 @@
"use client";
import React from "react";
import { Button, Textarea } from "ikoncomponents";
import { DecisionsData } from "../../types";
import { StepErrors } from "../../lib/validation";
interface DecisionsStepProps {
data: DecisionsData;
onChange: (field: keyof DecisionsData, value: string[]) => void;
errors: StepErrors;
}
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const DecisionsStep: React.FC<DecisionsStepProps> = ({ data, onChange, errors }) => {
const addDecision = () => onChange("decisions", [...data.decisions, ""]);
const updateDecision = (idx: number, val: string) => {
const updated = data.decisions.map((d, i) => (i === idx ? val : d));
onChange("decisions", updated);
};
const removeDecision = (idx: number) => {
onChange("decisions", data.decisions.filter((_, i) => i !== idx));
};
// Top-level array error (e.g. "At least one decision is required")
const arrayError = errors["decisions"];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
SI No
</span>
<Button
variant="outline"
size="sm"
onClick={addDecision}
className="text-emerald-600 border-emerald-300 hover:bg-emerald-50"
>
+ ADD
</Button>
</div>
{/* Array-level error */}
{arrayError && (
<p className="text-xs text-destructive">{arrayError}</p>
)}
{data.decisions.map((dec, idx) => {
// Row-level error: "decisions.0", "decisions.1", etc.
const rowErr = errors[`decisions.${idx}`];
return (
<div key={idx} className="flex gap-3 items-start">
<span className="text-sm text-muted-foreground font-medium w-6 mt-2">{idx + 1}</span>
<div className="flex-1">
<Textarea
rows={3}
value={dec}
onChange={(e) => updateDecision(idx, e.target.value)}
placeholder="Enter decision..."
className={rowErr ? "border-destructive" : ""}
/>
<FieldError message={rowErr} />
</div>
{data.decisions.length > 1 && (
<button
type="button"
onClick={() => removeDecision(idx)}
className="text-muted-foreground hover:text-destructive transition-colors text-xl leading-none mt-2"
>
×
</button>
)}
</div>
);
})}
</div>
);
};
export default DecisionsStep;

View File

@@ -0,0 +1,195 @@
"use client";
import React, { useEffect, useState } from "react";
import { Input, Label, Textarea, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ikoncomponents";
import { DURATION_UNIT_OPTIONS } from "../../lib/utils";
import { MeetingData, ApiUser } from "../../types";
import { StepErrors } from "../../lib/validation";
import { getUsersApi } from "@/app/utils/api/projectApi";
interface MeetingStepProps {
data: MeetingData;
onChange: (field: keyof MeetingData, value: string) => void;
errors: StepErrors;
}
// Small helper to render an error message under a field
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const MeetingStep: React.FC<MeetingStepProps> = ({ data, onChange, errors }) => {
const [users, setUsers] = useState<ApiUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
useEffect(() => {
const fetchUsers = async () => {
setLoadingUsers(true);
try {
const result: ApiUser[] = await getUsersApi();
setUsers(result.filter((u) => u.active && !u.userDeleted));
} catch (error) {
console.error("Failed to fetch users:", error);
} finally {
setLoadingUsers(false);
}
};
fetchUsers();
}, []);
return (
<div className="space-y-4">
{/* Title / Objective */}
<div className="space-y-1.5">
<Label htmlFor="title">
Title / Objective <span className="text-destructive">*</span>
</Label>
<Input
id="title"
value={data.title}
onChange={(e) => onChange("title", e.target.value)}
placeholder="Meeting title or objective"
className={errors.title ? "border-destructive" : ""}
/>
<FieldError message={errors.title} />
</div>
{/* Project Name + Place */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="projectName">
Project Name <span className="text-destructive">*</span>
</Label>
<Input
id="projectName"
value={data.projectName}
onChange={(e) => onChange("projectName", e.target.value)}
placeholder="Project name"
className={errors.projectName ? "border-destructive" : ""}
/>
<FieldError message={errors.projectName} />
</div>
<div className="space-y-1.5">
<Label htmlFor="place">
Place <span className="text-destructive">*</span>
</Label>
<Input
id="place"
value={data.place}
onChange={(e) => onChange("place", e.target.value)}
placeholder="Enter location"
className={errors.place ? "border-destructive" : ""}
/>
<FieldError message={errors.place} />
</div>
</div>
{/* Date + Time + Duration */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label htmlFor="date">
Date <span className="text-destructive">*</span>
</Label>
<Input
id="date"
type="date"
value={data.date}
onChange={(e) => onChange("date", e.target.value)}
className={errors.date ? "border-destructive" : ""}
/>
<FieldError message={errors.date} />
</div>
<div className="space-y-1.5">
<Label htmlFor="time">
Time <span className="text-destructive">*</span>
</Label>
<Input
id="time"
type="time"
value={data.time}
onChange={(e) => onChange("time", e.target.value)}
className={errors.time ? "border-destructive" : ""}
/>
<FieldError message={errors.time} />
</div>
<div className="space-y-1.5">
<Label>
Duration <span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<div className="flex flex-col">
<Input
type="number"
placeholder="0"
value={data.duration}
onChange={(e) => onChange("duration", e.target.value)}
className={`w-20 ${errors.duration ? "border-destructive" : ""}`}
/>
<FieldError message={errors.duration} />
</div>
<div className="flex flex-col flex-1">
<Select
value={data.durationUnit}
onValueChange={(v) => onChange("durationUnit", v)}
>
<SelectTrigger className={`flex-1 ${errors.durationUnit ? "border-destructive" : ""}`}>
<SelectValue placeholder="Unit" />
</SelectTrigger>
<SelectContent>
{DURATION_UNIT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.durationUnit} />
</div>
</div>
</div>
</div>
{/* Called By */}
<div className="space-y-1.5">
<Label>
Called By <span className="text-destructive">*</span>
</Label>
<Select
value={data.calledBy || ""}
onValueChange={(v) => onChange("calledBy", v)}
disabled={loadingUsers}
>
<SelectTrigger className={`w-full ${errors.calledBy ? "border-destructive" : ""}`}>
<SelectValue
placeholder={
loadingUsers
? "Loading users..."
: users.length === 0
? "No users found"
: "Select Called By"
}
/>
</SelectTrigger>
<SelectContent className="w-full max-h-60 overflow-y-auto">
{users.length === 0 && !loadingUsers && (
<div className="px-2 py-1 text-sm text-muted-foreground">
No active users available
</div>
)}
{users.map((user) => (
<SelectItem key={user.userId} value={String(user.userId)} className="truncate">
{user.userName}
{user.userDesignation ? ` (${user.userDesignation})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.calledBy} />
</div>
</div>
);
};
export default MeetingStep;

View File

@@ -0,0 +1,36 @@
"use client";
import React from "react";
import { Label, Textarea } from "ikoncomponents";
import { OthersData } from "../../types";
import { StepErrors } from "../../lib/validation";
interface OthersStepProps {
data: OthersData;
onChange: (field: keyof OthersData, value: string) => void;
errors: StepErrors;
}
const FieldError = ({ message }: { message?: string }) =>
message ? <p className="text-xs text-destructive mt-1">{message}</p> : null;
const OthersStep: React.FC<OthersStepProps> = ({ data, onChange, errors }) => {
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="notes">Notes / Additional Information</Label>
<Textarea
id="notes"
rows={5}
value={data.notes ?? ""}
onChange={(e) => onChange("notes", e.target.value)}
placeholder="Enter any additional notes or remarks..."
className={errors.notes ? "border-destructive" : ""}
/>
<FieldError message={errors.notes} />
</div>
</div>
);
};
export default OthersStep;

View File

@@ -0,0 +1,125 @@
import React from "react";
import { cn } from "../lib/utils";
import { SelectOption } from "../types";
// ---- Button ----
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "lg" | "icon";
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = "default",
size = "default",
className,
...props
}) => {
const base =
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
const variants: Record<string, string> = {
default: "bg-slate-900 text-white hover:bg-slate-800",
outline: "border border-slate-200 bg-white hover:bg-slate-50 text-slate-900",
ghost: "hover:bg-slate-100 text-slate-700",
};
const sizes: Record<string, string> = {
default: "h-9 px-4 py-2 text-sm",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-6 text-sm",
icon: "h-9 w-9",
};
return (
<button className={cn(base, variants[variant], sizes[size], className)} {...props}>
{children}
</button>
);
};
// ---- Input ----
export const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = ({
className,
...props
}) => (
<input
className={cn(
"flex h-9 w-full rounded-md border border-slate-200 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
// ---- Textarea ----
export const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = ({
className,
...props
}) => (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
className
)}
{...props}
/>
);
// ---- Label ----
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean;
}
export const Label: React.FC<LabelProps> = ({
children,
className,
required,
...props
}) => (
<label className={cn("text-sm font-medium text-slate-700", className)} {...props}>
{children}
{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
);
// ---- Select ----
export interface SelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder: string;
className?: string;
}
export const Select: React.FC<SelectProps> = ({
value,
onChange,
options,
placeholder,
className,
}) => (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cn(
"flex h-9 w-full rounded-md border border-slate-200 bg-white px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
!value && "text-slate-400",
className
)}
>
<option value="">{placeholder}</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value} className="text-slate-900">
{opt.label}
</option>
))}
</select>
);
// ---- Card ----
export const Card: React.FC<{ children: React.ReactNode; className?: string }> = ({
children,
className,
}) => (
<div className={cn("rounded-xl border border-slate-200 bg-white shadow-sm", className)}>
{children}
</div>
);

View File

@@ -0,0 +1,11 @@
import { FormData, StepKey } from "../types";
export const stepKeys: StepKey[] = [
"meeting",
"attendees",
"agenda",
"decisions",
"actions",
"others",
];

View File

@@ -0,0 +1,65 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { FormData, StepKey } from "../types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const RESPONSIBLE_OPTIONS = [
{ value: "nirupam", label: "Nirupam Patra" },
{ value: "poulami", label: "Poulami Kuti" },
{ value: "dev", label: "Developers Team" },
];
export const ROLE_OPTIONS = [
{ value: "pm", label: "Project Manager" },
{ value: "dev", label: "Developer" },
{ value: "design", label: "Designer" },
];
export const DURATION_UNIT_OPTIONS = [
{ value: "minutes", label: "Minutes" },
{ value: "hours", label: "Hours" },
];
export const STEPS = [
{ id: 1, label: "Meeting" },
{ id: 2, label: "Attendees" },
{ id: 3, label: "Agenda" },
{ id: 4, label: "Decisions" },
{ id: 5, label: "Actions" },
{ id: 6, label: "Others" },
];
export const STEP_KEYS: StepKey[] = [
"meeting",
"attendees",
"agenda",
"decisions",
"actions",
"others",
];
export const getInitialFormData = (projectName: string): FormData => ({
meeting: {
title: "",
projectName: projectName,
place: "",
date: "",
time: "",
duration: "",
durationUnit: "minutes",
calledBy: "",
externalCalledBy: "",
},
attendees: { attendees: [] },
agenda: { agenda: [] },
decisions: { decisions: [] },
actions: { actions: [] },
others: { notes: "" },
});

View File

@@ -0,0 +1,101 @@
import { z } from "zod";
// ─── Step 1: Meeting ────────────────────────────────────────────────────────
export const meetingSchema = z.object({
title: z.string().min(1, "Title / Objective is required"),
projectName: z.string().min(1, "Project Name is required"),
place: z.string().min(1, "Place is required"),
date: z.string().min(1, "Date is required"),
time: z.string().min(1, "Time is required"),
duration: z
.string()
.min(1, "Duration is required")
.refine((v) => Number(v) > 0, { message: "Duration must be greater than 0" }),
durationUnit: z.string().min(1, "Duration unit is required"),
calledBy: z.string().min(1, "Called By is required"),
});
// ─── Step 2: Attendees ──────────────────────────────────────────────────────
export const attendeeSchema = z.object({
name: z.string().min(1, "Attendee name is required"),
role: z.string().min(1, "Role is required"),
contact: z.string().optional(),
});
export const attendeesSchema = z.object({
attendees: z
.array(attendeeSchema)
.min(1, "At least one attendee is required"),
});
// ─── Step 3: Agenda ─────────────────────────────────────────────────────────
export const agendaItemSchema = z.object({
item: z.string().min(1, "Agenda item is required"),
owner: z.string().min(1, "Owner / Presenter is required"),
});
export const agendaSchema = z.object({
agenda: z
.array(agendaItemSchema)
.min(1, "At least one agenda item is required"),
});
// ─── Step 4: Decisions ──────────────────────────────────────────────────────
export const decisionsSchema = z.object({
decisions: z
.array(z.string().min(1, "Decision cannot be empty"))
.min(1, "At least one decision is required"),
});
// ─── Step 5: Actions ────────────────────────────────────────────────────────
export const actionItemSchema = z.object({
item: z.string().min(1, "Action item is required"),
responsible: z.string().min(1, "Responsible person is required"),
dueDate: z.string().min(1, "Due date is required"),
});
export const actionsSchema = z.object({
actions: z
.array(actionItemSchema)
.min(1, "At least one action item is required"),
});
// ─── Step 6: Others ─────────────────────────────────────────────────────────
// Adjust fields to match your OthersData shape
export const othersSchema = z.object({
notes: z.string().optional(),
});
// ─── Step schema map (index = step number) ──────────────────────────────────
export const stepSchemas: Record<number, z.ZodTypeAny> = {
1: meetingSchema,
2: attendeesSchema,
3: agendaSchema,
4: decisionsSchema,
5: actionsSchema,
6: othersSchema,
};
// ─── Utility: validate a single step ────────────────────────────────────────
export type StepErrors = Record<string, string>;
export function validateStep(step: number, data: unknown): StepErrors {
const schema = stepSchemas[step];
if (!schema) return {};
const result = schema.safeParse(data);
if (result.success) return {};
const errors: StepErrors = {};
const zodError = result.error as z.ZodError;
zodError.issues.forEach((issue) => {
// path examples: ["title"], ["attendees", 0, "name"]
const key = issue.path.join(".");
if (!errors[key]) errors[key] = issue.message;
});
return errors;
}
// ─── Utility: does a step have errors? ──────────────────────────────────────
export function stepHasErrors(step: number, data: unknown): boolean {
return Object.keys(validateStep(step, data)).length > 0;
}

View File

@@ -0,0 +1,81 @@
export interface Attendee {
name: string;
role: string;
contact: string;
}
export interface AgendaItem {
item: string;
owner: string;
}
export interface ActionItem {
item: string;
responsible: string;
dueDate: string;
}
export interface MeetingData {
title: string;
projectName: string;
place: string;
date: string;
time: string;
duration: string;
durationUnit: string;
calledBy: string;
externalCalledBy: string;
}
export interface AttendeesData {
attendees: Attendee[];
}
export interface AgendaData {
agenda: AgendaItem[];
}
export interface DecisionsData {
decisions: string[];
}
export interface ActionsData {
actions: ActionItem[];
}
export interface OthersData {
notes: string;
}
export interface FormData {
meeting: MeetingData;
attendees: AttendeesData;
agenda: AgendaData;
decisions: DecisionsData;
actions: ActionsData;
others: OthersData;
}
export type StepKey = keyof FormData;
export interface SelectOption {
value: string;
label: string;
}
export interface ApiUser {
userId: string;
userName: string;
userLogin: string;
userPhone: string;
userEmail: string;
userThumbnail: string | null;
userType: string;
active: boolean;
dateOfBirth: string | null;
userProfileImage: string | null;
userDescription: string | null;
userDesignation: string | null;
invitedUser: boolean;
userDeleted: boolean;
}

View File

@@ -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>
);
}