first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FormData, StepKey } from "../types";
|
||||
|
||||
export const stepKeys: StepKey[] = [
|
||||
"meeting",
|
||||
"attendees",
|
||||
"agenda",
|
||||
"decisions",
|
||||
"actions",
|
||||
"others",
|
||||
];
|
||||
|
||||
@@ -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: "" },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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