This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -0,0 +1,198 @@
sentationsand premos r de
- Better foolisheds more p- Looknal**
fessio✅ **Proages
ror messr er Bettegs
-in losources ntify re to ide*
- Easierbugging***De
✅ tive URLsscripre dengines
- Mo eearch s- Better forable)
plic** (if apEO **S
✅ bookmark
hare and Easier to se
-re they ar whe understands canul
- Useraningfdable and me are rea
- URLsrience**r Expe
✅ **Use## Benefitstime
oute at a e one rgratmiy Graduallnd slugs
3.th UUIDs as bohich handleram()` wSlugPaarse2. Use `phanges
ute crt rove but reitiesp slug util:
1. Keearise issues k Plan
IfRollbac)
## on collisi names (slugte planuplica Dmes
-g nalon very thns wi- Pla in names
charactersial ecns with sp- Pla*
ge Cases* **Edns
4.peratioest CRUD oUIDs
- T still use Ull API callsify a**
- Ver*API Testes
3. * valuorrect show ceadcrumbsy br
- VerifLsnew UR with ation flowsavigest all n - Tst**
ion Te. **Navigat
2URLsookmarked t with b - Tesn
migratiouring ll work d stid UUID URLs- Ensure ol*
t*lity Tesmpatibiard Cokw
1. **BacyategStr## Testing ;
```
{planSlug}`)r}/plan/$gement/${yeaormance-manaate(`/perfavigd);
n), plan.ie(plan.nameam(localizedNlugteUniqueSea crlug =
const planSteration - Af
// Navigan.id}`);pl/plan/${Id}nt/${yearanagemeformance-m(`/pervigate
naion - Beforeigat;
// Nav|| '')(planSlug lugParamparseSd } = lanI pd:t { i;
consd)unitI, lanYear(yearResolvedPuse } = arIda, yeearDatata: planY
const { dams();eParusSlug } = lantionKey, pyear, posi
const {
// AfterarId);
ById(ye usePlanYeara } =YearDatlana: pconst { dat();
= useParams }, planIdtionIdrId, posi{ yea
const Before
// ``tsxonent
`ble Compte PlanTapdae: Uxampl## EnSlug}`
lan/\${pyear}/plaent/\${nce-managemrmaerfoigate(\`/p
- `nav\${year}`ent/emance-managormate(\`/perf
- `navigith:
Replace w\${planId}`r}/plan/\${yeant/managemee-/performancigate(\`
- `navarId}`\${yeement/mance-manag/perfore(\`- `navigatf:
s oncer all instaearch fo Calls
Savigation
### 3. NuLayout.tsx`formanceMenpert/layout/nagemene-mac/performanc`sr/*`
- rarchyents/PlanHieement/compon-managrformance `src/pesx`
-rm.tn/PlanFogurationfits/Plan-Componenement/coance-managformerrc/p `sable.tsx`
-n/planTratiolan-Configuponents/P/comgement-manaanceerform
- `src/psing Paramsmponents U
### 2. Coute>
```>} />
</RoPlanForm /ement={< ellug/edit"lan/:planSth="pRoute pa />} />
<ails={<PlanDetug" elementan/:planSl"plth=ute pa>
<Rorchy />} /eranHila={<Pment" elelansth="p <Route pat/:year">
managemenrformance-ath="peRoute par
<o ye tarIdall yege
// Chan`tsxtsx)
``s (App. 1. Route
###eUpdato Files t Key ons
##operatiCRUD t all
- [ ] Tesededre neolution wheo-ID resg-t ] Add slurnally
- [nte use UUIDs itill salls API callnsure [ ] Ealls
- I CUpdate APse 4:
### Phation flows
l naviga] Test alams()`
- [ useParng `mponents usie all codat ] Up- [Slug`
an `:pllanId` torom `:p params fange route
- [ ] Ch` `:yearId` to `:year params fromoute r [ ] Changege)
-hanking Coutes (Breaate R 3: Upd## Phase
#dable valuesuse rearumbs to pdate breadcrs
- [ ] Uear numbe to use yu linkse menat [ ] Upd
-lugso use plan ss tate()` callnavigate all ` [ ] Updmbers
- nuars to use yell` ca `navigate()date all [ ] Upation
-e Navig: Updatase 2 Phrk)
###ll would sti URLs (shoID UUith existing[ ] Test wlugs
- ss andUUIDth boto parse mponents Update coook
- [ ] hanYear`eResolvedPlus
- [x] Add `hookyNumber` nYearB`usePla [x] Add ons
-y functi utilitte slug
- [x] Creaatible)rd Comp(Backwaupport d Slug S Ad1: Phase st
###lickration Che`
## Migment
``nce-departnatable/filans-/ Use: /puuid
/c-123-table/abof: /plans-nstead
// ID:
```tsx of Iat insteadld, use th`key` fiea e ns havIf positioKeys
tion e Posions: Us 3. Positi
###
nId);
```n(pla } = usePlatalanDa data: p
const {lug);ram(planSugPaseSl } = parplanId id: nst {();
corams } = usePaanSlugst { plcons';
slugUtilt/utils/-managemenormancerom '@/perfram } fSlugParse part {
impoxs:**
```tsent compone slugs inrs**Pa
```
g}`);${planSlulan/t/2024/pemenmance-manag(`/perfor
navigate plan.id);ame),(plan.nzedNameocaliqueSlug(leUni = creatplanSlug
const g to a plann navigatin';
// Whetilstils/slugUmanagement/umance-for '@/pereSlug } fromiquUnatet { cresx
imporng:**
```ttivigahen naslugs we Generat
**Suffix
s with ID uglans: Use Sl. P
### 2
y
```r displa" fo24e "20ear will b/ ys
/for API callactual UUID be the arId will
// yed);r, unitIear(yeavedPlanYol useRes, yearId } =ta{ danst
coid;0]?.s?.[ata?.itemata?.d)?.dionId!zatorganitList(text?.getConunitId = uninst co();
itContextext = useUntContunid;
const tionI.organizaployee?.[0]?user?.emzationId = ni
const orga);Params(ar } = use{ yest w way
con
// Ne(yearId);
earByIdePlanY= usdata } st { ams();
conPar} = uset { yearId y
cons Old wasx
//*
```tve year:*olrests to ponen com
**Update
```nt/:year">eme-managerformance"pe path=tsx
<Rout:**
```
**To">
```arIdment/:yege-manaperformancee path="x
<Rout```ts:**
routes from
**Change NumbersYear s: Use lan Year# 1. Ps
##n Steptio Implementat
```
##5/edin-q2-abc1234lay-prterl/plan/quament/2024ance-managerforment
/pedepartmnce-s-table/finat/2024/plance-managemenmanerfor4/plans
/p/202nagementormance-maerf)
```
/pdable (Human-rea After
###`
``/edit
uuidhi-789-d/plan/guic-123-uent/abanagemnce-m/performa456-uuid
s-table/def-3-uuid/planment/abc-12ance-managerm/perfolans
-123-uuid/pt/abcanagemenrmance-m```
/perfoUID-based)
e (U# Beforhanges
##re CRL Structu.
## Uduleanagement moce mrmanin the perfole URLs readaban-to humased URLs D-bm UUIte froto migraains how xpl eideis gu
ThOverviews
## dable URLHuman-Rean Guide: gratio# Mi

View File

@@ -0,0 +1,969 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/shared/common/ui/use-toast";
import { useTranslation } from "react-i18next";
// Import your existing components
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Progress } from "@/shared/common/ui/progress";
import { cn } from "@/shared/lib/utils";
import {
AlertCircleIcon,
ArrowLeftIcon,
ChartBarIcon,
ClockIcon,
HourglassIcon,
Loader2,
PlayCircleIcon,
Settings2Icon,
TicketIcon,
UserIcon,
AlertTriangleIcon,
PhoneIcon,
CalendarIcon,
BuildingIcon,
Activity,
Zap,
Users,
CheckCircle,
RefreshCw,
TrendingUp,
BarChart3,
Phone,
Clock,
} from "lucide-react";
import { getApiErrorMessage } from "@/record-management/services/api/approvalService";
import {
useCallExecution,
useMyActiveExecution,
} from "@/queue-management/hooks/useExecution";
// Types
enum TransferTypes {
REASSIGN = "reassign",
TRANSFER = "transfer",
}
enum TicketStatus {
WAITING = "waiting",
CALLED = "called",
SERVING = "serving",
COMPLETED = "completed",
TRANSFERRED = "transferred",
SKIPPED = "skipped",
}
interface Ticket {
id: string;
ticketNumber: string;
serviceId: string;
service: {
name: string;
category: {
name: string;
};
};
counterId: string;
counter: {
windowNumber: string;
};
status: TicketStatus;
priority: "low" | "normal" | "high" | "urgent";
calledAt?: string;
servedAt?: string;
completedAt?: string;
transferredFrom?: any;
transferredFromId?: string | null;
transferType?: TransferTypes | null;
handlerId?: string;
handler?: {
firstName: string;
lastName: string;
windowNumber: string;
};
estimatedWaitTime?: number;
customerNotes?: string;
customerName?: string;
customerPhone?: string;
queueGroup?: {
customerName?: {
en: string;
am: string;
};
customerPhone?: string;
};
}
const ActiveCallManagement = () => {
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useTranslation();
// State
const [activeTicket, setActiveTicket] = useState<Ticket | null>(null);
const [serviceTime, setServiceTime] = useState(0);
const [isTimeExceeded, setIsTimeExceeded] = useState(false);
const [waitTime, setWaitTime] = useState(0);
const { mutate: callExecution, isPending: isCalling } = useCallExecution();
// Fetch active execution to show notifications
const { data: myActiveExecutionData, refetch: refetchActiveExecution } =
useMyActiveExecution();
// Auto-refresh active execution data every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
refetchActiveExecution();
}, 30000); // 30 seconds
return () => clearInterval(interval);
}, [refetchActiveExecution]);
// Update activeTicket when myActiveExecutionData changes
useEffect(() => {
if (myActiveExecutionData) {
// Extract customer information
const customerName =
myActiveExecutionData.queue?.queueGroup?.customerName?.en ||
myActiveExecutionData.queue?.queueGroup?.customerName?.am ||
t("activeCallManagement.customer");
const customerPhone =
myActiveExecutionData.queue?.queueGroup?.customerPhone ||
t("common.na", "N/A");
// Map execution data to Ticket format
const mappedTicket: Ticket = {
id: myActiveExecutionData.id,
ticketNumber: myActiveExecutionData.queue?.positionInQueue
? `${myActiveExecutionData.service?.prefix || "T"}-${
myActiveExecutionData.queue.positionInQueue
}`
: myActiveExecutionData.queueNumber || t("common.na", "N/A"),
serviceId: myActiveExecutionData.serviceId || "",
service: {
name:
myActiveExecutionData.service?.name?.en ||
t("activeCallManagement.service"),
category: {
name:
myActiveExecutionData.service?.name?.en ||
t("activeCallManagement.category"),
},
},
counterId: myActiveExecutionData.employeePositionId || "",
counter: {
windowNumber: myActiveExecutionData.caseWorkerName?.en || "1",
},
status:
myActiveExecutionData.status === "STARTED"
? TicketStatus.SERVING
: myActiveExecutionData.status === "STOPPED"
? TicketStatus.CALLED
: TicketStatus.COMPLETED,
priority: "normal" as const,
calledAt: myActiveExecutionData.calledAt,
servedAt: myActiveExecutionData.startedAt,
completedAt: myActiveExecutionData.endedAt,
transferredFrom: undefined,
transferredFromId: myActiveExecutionData.parentExecutionId,
transferType: null,
handlerId: myActiveExecutionData.caseWorkerId,
handler: myActiveExecutionData.caseWorkerName
? {
firstName:
myActiveExecutionData.caseWorkerName.en.split(" ")[0] || "",
lastName:
myActiveExecutionData.caseWorkerName.en.split(" ")[1] || "",
windowNumber: "1",
}
: undefined,
estimatedWaitTime: myActiveExecutionData.service?.estimatedTime,
customerNotes: myActiveExecutionData.queue?.queueGroup?.customerPhone
? `Customer: ${myActiveExecutionData.queue.queueGroup.customerName.en} (${myActiveExecutionData.queue.queueGroup.customerPhone})`
: undefined,
customerName: customerName,
customerPhone: customerPhone,
queueGroup: myActiveExecutionData.queue?.queueGroup,
};
setActiveTicket(mappedTicket);
} else {
// Clear active ticket if no execution data
setActiveTicket(null);
}
}, [myActiveExecutionData, t]);
useEffect(() => {
let interval: number;
if (activeTicket?.status === TicketStatus.SERVING) {
interval = window.setInterval(() => {
setServiceTime((prev) => {
const newTime = prev + 1;
// Check if time exceeded estimated time (convert minutes to seconds)
const estimatedSeconds = (activeTicket.estimatedWaitTime || 0) * 60;
setIsTimeExceeded(newTime > estimatedSeconds && estimatedSeconds > 0);
return newTime;
});
}, 1000);
}
return () => clearInterval(interval);
}, [activeTicket?.status, activeTicket?.estimatedWaitTime]);
useEffect(() => {
let interval: number;
if (activeTicket?.status === TicketStatus.CALLED) {
interval = window.setInterval(() => {
setWaitTime((prev) => prev + 1);
}, 1000);
}
return () => clearInterval(interval);
}, [activeTicket?.status]);
// Handlers
const handleCall = useCallback(() => {
callExecution(undefined, {
onSuccess: (data) => {
setActiveTicket(data);
setWaitTime(0);
toast({
title: t("activeCallManagement.ticketCalled"),
description: `${t("activeCallManagement.ticket")} ${
data.ticketNumber
} ${t("activeCallManagement.ticketCalledMessage")}`,
variant: "default",
});
},
onError: (error) => {
toast({
title: t("activeCallManagement.callFailed"),
description: getApiErrorMessage(
error,
t("activeCallManagement.callFailedMessage")
),
variant: "destructive",
});
},
});
}, [callExecution, toast, t]);
// Helper functions
const formatTime = useCallback((seconds: number) => {
const h = Math.floor(seconds / 3600)
.toString()
.padStart(2, "0");
const m = Math.floor((seconds % 3600) / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${h}:${m}:${s}`;
}, []);
const getPriorityColor = useCallback((priority: string) => {
switch (priority) {
case "urgent":
return "bg-red-100 text-red-800 border-red-200";
case "high":
return "bg-orange-100 text-orange-800 border-orange-200";
case "normal":
return "bg-blue-100 text-blue-800 border-blue-200";
default:
return "bg-gray-100 text-gray-800 border-gray-200";
}
}, []);
// Format date for display
const formatDate = useCallback((dateString?: string) => {
if (!dateString) return t("common.na", "N/A");
try {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
} catch {
return dateString;
}
}, []);
// Memoized components
const TicketDisplay = useMemo(() => {
if (!activeTicket) {
return (
<Card className="h-full flex items-center justify-center border-dashed border-2 border-gray-200 bg-gradient-to-br from-gray-50 to-white rounded-2xl">
<CardContent className="py-16 text-center">
<div className="relative inline-block mb-6">
<div className="h-32 w-32 rounded-full bg-gradient-to-r from-blue-100 to-blue-50 flex items-center justify-center animate-pulse">
<TicketIcon className="h-16 w-16 text-blue-300" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center shadow-lg">
<PlayCircleIcon className="h-6 w-6 text-white" />
</div>
</div>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-3">
{t("activeCallManagement.readyToServe")}
</h3>
<p className="text-gray-600 max-w-md mx-auto mb-8 text-lg">
{t("activeCallManagement.noActiveCustomer")}
</p>
<div className="flex items-center justify-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Users className="w-4 h-4" />
<span>
{t(
"activeCallManagement.waitingCustomers",
"Waiting customers"
)}
</span>
</div>
<div className="w-1 h-1 bg-gray-300 rounded-full"></div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>
{t(
"activeCallManagement.realTimeUpdates",
"Real-time updates"
)}
</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Customer Information Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div
className={cn(
"p-6 text-white",
activeTicket.status === TicketStatus.CALLED
? "bg-gradient-to-r from-yellow-500 to-orange-500"
: "bg-gradient-to-r from-purple-500 to-indigo-500"
)}
>
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<UserIcon className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-lg">
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.customerCalled")
: t("activeCallManagement.currentlyServing")}
</h3>
<p className="text-white/90 text-sm">
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.waitingAtCounter")
: t("activeCallManagement.assistingCustomer")}
</p>
</div>
</div>
<Badge className="bg-white/20 backdrop-blur-sm text-white border-0 px-3 py-1">
<Phone className="h-3 w-3 mr-1" />
{activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.called")
: t("activeCallManagement.serving")}
</Badge>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-6">
{/* Customer Name and Ticket */}
<div className="text-center p-6 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl">
<div className="text-sm text-blue-600 font-medium mb-2">
{t("activeCallManagement.customer")}
</div>
<h2 className="text-3xl font-bold text-blue-900 mb-2">
{activeTicket.customerName ||
t("activeCallManagement.customer")}
</h2>
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full border border-blue-200">
<TicketIcon className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-800">
{t("activeCallManagement.ticket")} #
{activeTicket.ticketNumber}
</span>
</div>
</div>
{/* Customer Details Grid */}
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<PhoneIcon className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="text-sm text-primary-600 font-medium">
{t("activeCallManagement.phoneNumber")}
</div>
<div className="font-semibold text-green-900">
{activeTicket.customerPhone || t("common.na", "N/A")}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<CalendarIcon className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-purple-600 font-medium">
{t("activeCallManagement.calledAt", "Called At")}
</div>
<div className="font-semibold text-purple-900">
{formatDate(activeTicket.calledAt)}
</div>
</div>
</div>
</div>
</div>
{/* Priority Badge */}
{activeTicket.priority && activeTicket.priority !== "normal" && (
<div className="flex items-center justify-center">
<Badge
className={cn(
"px-4 py-2 text-sm font-semibold",
getPriorityColor(activeTicket.priority)
)}
>
<AlertTriangleIcon className="h-4 w-4 mr-2" />
{activeTicket.priority.toUpperCase()}{" "}
{t("activeCallManagement.priority", "PRIORITY")}
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
{/* Service Information Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-blue-600 to-cyan-500 text-white">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
<Settings2Icon className="w-6 h-6" />
</div>
<div>
<h3 className="font-bold text-lg">
{t("activeCallManagement.serviceInformation")}
</h3>
<p className="text-white/90 text-sm">
{t("activeCallManagement.currentServiceDetails")}
</p>
</div>
</div>
<Badge className="bg-white/20 backdrop-blur-sm text-white border-0 px-3 py-1">
<Settings2Icon className="h-3 w-3 mr-1" />
{t("activeCallManagement.service")}
</Badge>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-6">
{/* Service Name Display */}
<div className="text-center p-6 bg-gradient-to-r from-cyan-50 to-blue-50 rounded-xl">
<div className="text-sm text-cyan-600 font-medium mb-2">
{t(
"activeCallManagement.currentlyServingLabel",
"Currently Serving"
)}
</div>
<h2 className="text-2xl font-bold text-cyan-900 mb-4">
{typeof activeTicket.service?.name === "object"
? (activeTicket.service?.name as any)?.en ||
(activeTicket.service?.name as any)?.am ||
t("activeCallManagement.service")
: activeTicket.service?.name}
</h2>
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full border border-cyan-200">
<BuildingIcon className="h-4 w-4 text-cyan-600" />
<span className="text-sm font-medium text-cyan-800">
{t("activeCallManagement.window")}{" "}
{activeTicket.counter?.windowNumber ||
t("common.na", "N/A")}
</span>
</div>
</div>
{/* Service Details */}
<div className="grid grid-cols-1 gap-4">
<div className="flex items-center justify-between p-4 bg-orange-50 rounded-lg border border-orange-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<ClockIcon className="h-5 w-5 text-orange-600" />
</div>
<div>
<div className="text-sm text-orange-600 font-medium">
{t("activeCallManagement.estimatedTime", "Est. Time")}
</div>
<div className="font-semibold text-orange-900">
{activeTicket.estimatedWaitTime
? `${activeTicket.estimatedWaitTime} ${t(
"activeCallManagement.minutes",
"minutes"
)}`
: t(
"activeCallManagement.notSpecified",
"Not specified"
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 bg-indigo-50 rounded-lg border border-indigo-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center">
<ChartBarIcon className="h-5 w-5 text-indigo-600" />
</div>
<div>
<div className="text-sm text-indigo-600 font-medium">
{t("activeCallManagement.status")}
</div>
<div className="font-semibold text-indigo-900 capitalize">
{activeTicket.status === TicketStatus.SERVING
? t("activeCallManagement.inProgress")
: activeTicket.status === TicketStatus.CALLED
? t("activeCallManagement.waiting")
: activeTicket.status}
</div>
</div>
</div>
</div>
</div>
{/* Service Start Time */}
{activeTicket.servedAt && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-primary-600" />
</div>
<div>
<div className="text-sm font-medium text-green-800">
{t("activeCallManagement.serviceStarted")}
</div>
<div className="text-sm text-primary-700">
{formatDate(activeTicket.servedAt)}
</div>
</div>
</div>
</div>
)}
{/* Customer Notes */}
{activeTicket.customerNotes && (
<div className="p-4 bg-gradient-to-r from-amber-50 to-yellow-50 rounded-lg border border-amber-200">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-lg flex items-center justify-center mt-0.5">
<AlertCircleIcon className="h-5 w-5 text-amber-600" />
</div>
<div>
<div className="text-sm font-medium text-amber-800 mb-1">
{t("activeCallManagement.customerNotes")}
</div>
<div className="text-sm text-amber-700">
{activeTicket.customerNotes}
</div>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}, [activeTicket, handleCall, isCalling, getPriorityColor, formatDate, t]);
const ServingTime = () => {
if (!activeTicket) return null;
const time =
activeTicket.status === TicketStatus.SERVING ? serviceTime : waitTime;
const isServing = activeTicket.status === TicketStatus.SERVING;
const maxTime = (activeTicket.estimatedWaitTime || 5) * 60;
const progressValue = Math.min((time / maxTime) * 100, 100);
const isOverdue = isTimeExceeded;
return (
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div
className={cn(
"p-6",
isServing
? isOverdue
? "bg-gradient-to-r from-red-500 to-rose-500"
: "bg-gradient-to-r from-green-500 to-emerald-500"
: isOverdue
? "bg-gradient-to-r from-red-500 to-rose-500"
: "bg-gradient-to-r from-yellow-500 to-amber-500"
)}
>
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-white/20 rounded-2xl flex items-center justify-center">
<ClockIcon className="w-8 h-8" />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">
{isServing
? t("activeCallManagement.serviceTime")
: t("activeCallManagement.customerWaitTime")}
</h3>
<p className="text-white/90">
{isServing
? isOverdue
? `⚠️ ${t(
"activeCallManagement.exceedingEstimatedTime"
)} ${activeTicket.customerName}`
: `${t("activeCallManagement.assisting")} ${
activeTicket.customerName
}`
: `${activeTicket.customerName} ${t(
"activeCallManagement.waitingAtCounterLabel"
)}`}
</p>
</div>
</div>
<div className="text-right">
<div className="text-5xl font-bold font-mono mb-2">
{formatTime(time)}
</div>
<div className="flex items-center gap-2">
<Badge
className={cn(
"px-3 py-1.5 text-sm font-semibold",
isOverdue
? "bg-white/20 text-white"
: "bg-white/20 text-white"
)}
>
{progressValue.toFixed(0)}%
</Badge>
<span className="text-sm text-white/80">
{Math.floor(maxTime / 60)}m {t("activeCallManagement.target")}
</span>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 p-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<ClockIcon className="w-5 h-5 text-gray-600" />
<span className="text-sm font-medium text-gray-700">
{t("activeCallManagement.timeElapsed")}
</span>
</div>
<div className="text-lg font-mono font-semibold text-gray-900">
{Math.floor(time / 60)}m {time % 60}s
</div>
</div>
<div className="flex items-center gap-2">
{isOverdue && (
<Badge variant="destructive" className="gap-1">
<AlertTriangleIcon className="w-3 h-3" />
{t("activeCallManagement.overdue", "Overdue")}
</Badge>
)}
<Badge
variant={isServing ? "success" : "warning"}
className="gap-1"
>
<Activity className="w-3 h-3" />
{isServing
? t("activeCallManagement.serving")
: t("activeCallManagement.waiting")}
</Badge>
</div>
</div>
<Progress
value={progressValue}
className={cn(
"h-3 rounded-full",
isOverdue
? "bg-red-100 [&>div]:bg-gradient-to-r [&>div]:from-red-500 [&>div]:to-rose-500"
: isServing
? "bg-green-100 [&>div]:bg-gradient-to-r [&>div]:from-green-500 [&>div]:to-emerald-500"
: "bg-yellow-100 [&>div]:bg-gradient-to-r [&>div]:from-yellow-500 [&>div]:to-amber-500"
)}
/>
<div className="flex justify-between text-xs text-gray-500 mt-2">
<span>0m</span>
<span>{Math.floor(maxTime / 60)}m</span>
</div>
</div>
</Card>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 p-4 md:p-6">
<div className="max-w-7xl mx-auto">
{/* Enhanced Header */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-6 mb-8">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 hover:border-blue-400 hover:bg-blue-50 gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
{t("activeCallManagement.back", "Back")}
</Button>
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-500 rounded-2xl flex items-center justify-center shadow-lg">
<Activity className="w-8 h-8 text-white" />
</div>
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 via-cyan-600 to-indigo-600 bg-clip-text text-transparent mb-2">
{t("activeCallManagement.title", "Active Call Management")}
</h1>
<p className="text-gray-600 text-lg flex items-center gap-2">
<Zap className="w-5 h-5 text-blue-500" />
{t(
"activeCallManagement.description",
"Manage active customer calls and service execution in real-time"
)}
</p>
</div>
</div>
</div>
{/* Real-time Status and Controls */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-4 py-2 bg-white/80 backdrop-blur-sm rounded-xl shadow-sm border">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<span className="text-sm font-medium text-gray-700">
{t("activeCallManagement.realTimeUpdates", "Real-time Updates")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-sm text-gray-500">
{t("activeCallManagement.serviceCounter", "Service Counter")}
</p>
<Badge className="bg-gradient-to-r from-blue-500 to-blue-600 text-white px-3 py-1.5">
{t("activeCallManagement.window", "Window")}{" "}
{activeTicket?.counter?.windowNumber || t("common.na", "N/A")}
</Badge>
</div>
<div className="h-8 w-px bg-gray-300" />
<div className="text-right">
<p className="text-sm text-gray-500">
{t("activeCallManagement.status", "Status")}
</p>
<Badge
className={cn(
"px-3 py-1.5",
activeTicket
? "bg-gradient-to-r from-green-500 to-emerald-500 text-white"
: "bg-gray-100 text-gray-800"
)}
>
{activeTicket
? t("activeCallManagement.active", "Active")
: t("activeCallManagement.ready", "Ready")}
</Badge>
</div>
</div>
</div>
</div>
{/* Enhanced Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Sidebar - Quick Actions & Stats */}
<div className="lg:col-span-1 space-y-6">
{/* Quick Actions Card */}
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-blue-600 to-blue-500 text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<Zap className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-lg">
{t("activeCallManagement.quickActions")}
</h3>
<p className="text-blue-100 text-sm">
{t("activeCallManagement.commonOperations")}
</p>
</div>
</div>
</div>
<CardContent className="p-6 space-y-4">
{/* Call Next Customer Button */}
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-primary hover:to-emerald-600 text-white shadow-lg hover:shadow-xl transition-all duration-200 h-12"
onClick={() => handleCall()}
disabled={isCalling || !!activeTicket}
>
{isCalling ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
{t("activeCallManagement.calling")}
</>
) : (
<>
<PlayCircleIcon className="h-5 w-5 mr-2" />
{t("activeCallManagement.callNextCustomer")}
</>
)}
</Button>
{/* Action Buttons Grid */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
disabled={!activeTicket}
>
<HourglassIcon className="h-4 w-4" />
{t("activeCallManagement.hold")}
</Button>
<Button
variant="outline"
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
disabled={!activeTicket}
>
<Settings2Icon className="h-4 w-4" />
{t("activeCallManagement.transfer")}
</Button>
<Button
variant="outline"
onClick={() => {
refetchActiveExecution();
}}
className="border-gray-300 text-gray-700 hover:bg-gray-50 h-10 gap-2"
>
<RefreshCw className="h-4 w-4" />
{t("activeCallManagement.refresh", "Refresh")}
</Button>
</div>
</CardContent>
</Card>
{/* Customer Stats Card */}
{activeTicket && (
<Card className="bg-white/90 backdrop-blur-sm border-0 shadow-lg rounded-2xl overflow-hidden">
<div className="p-6 bg-gradient-to-r from-purple-600 to-violet-500 text-white">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<BarChart3 className="w-5 h-5" />
</div>
<div>
<h3 className="font-semibold text-lg">
{t("activeCallManagement.customerStats")}
</h3>
<p className="text-purple-100 text-sm">
{t(
"activeCallManagement.realTimeMetrics",
"Real-time metrics"
)}
</p>
</div>
</div>
</div>
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2">
<Settings2Icon className="h-4 w-4 text-blue-600" />
<span className="text-sm text-blue-700 font-medium">
{t("activeCallManagement.currentService")}
</span>
</div>
<span className="font-medium text-blue-900 text-sm">
{typeof activeTicket.service?.name === "object"
? (activeTicket.service?.name as any)?.en ||
(activeTicket.service?.name as any)?.am ||
t("common.na", "N/A")
: activeTicket.service?.name}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-yellow-50 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-yellow-600" />
<span className="text-sm text-yellow-700 font-medium">
{t("activeCallManagement.waitTime")}
</span>
</div>
<span className="font-medium text-yellow-900 font-mono">
{formatTime(waitTime)}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-primary-600" />
<span className="text-sm text-primary-700 font-medium">
{t("activeCallManagement.serviceTime")}
</span>
</div>
<span className="font-medium text-green-900 font-mono">
{formatTime(serviceTime)}
</span>
</div>
<div className="flex items-center justify-between p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-purple-600" />
<span className="text-sm text-purple-700 font-medium">
{t("activeCallManagement.priority")}
</span>
</div>
<Badge
className={cn(
"px-2 py-0.5",
getPriorityColor(activeTicket.priority)
)}
>
{activeTicket.priority}
</Badge>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Main Content Area */}
<div className="lg:col-span-3 space-y-6">
{/* Timer Card */}
{activeTicket && <ServingTime />}
{/* Customer and Service Display */}
{TicketDisplay}
</div>
</div>
</div>
</div>
);
};
export default ActiveCallManagement;

View File

@@ -0,0 +1,803 @@
import { useForm, useWatch } from "react-hook-form";
import { motion } from "framer-motion";
import {
Calendar,
Target,
Users,
FileText,
Plus,
ArrowLeft,
User,
} from "lucide-react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import { Info } from "lucide-react";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { TimeFrame } from "@/performance-management/utils/shared-field-types";
import {
useEmployeePlanMutations,
useEmployeePositions,
usePositionsByUnit,
useEmployeePlan,
useServices,
useEmployeePlanByPlanId,
} from "@/performance-management/hooks/useEmployeePlan";
import { FormField } from "@/performance-management/utils/shared-field-types";
import { useMinePlan } from "@/performance-management/hooks/usePlans";
// Define PlanType enum for employee plans
export enum EmployeePlanType {
NUMBER = "number",
PERCENTAGE = "percent",
BOOLEAN = "boolean",
}
export interface EmployeePlanFormValues {
parentEmployeePlanId?: string;
employeePositionId: string;
planId: string;
serviceId: string;
planType: EmployeePlanType;
expectedQuantity: number;
week: string | number;
month: string | number;
quarter: string | number;
timeframe: string;
}
interface EmployeePlanFormProps {
parentEmployeePlanId?: string;
onSuccess?: () => void;
}
export default function EmployeePlanForm({
parentEmployeePlanId: propParentId,
onSuccess,
}: EmployeePlanFormProps = {}) {
const navigate = useNavigate();
const { t } = useTranslation();
const {
id,
employeePositionId: pathEmployeePositionId,
planId: pathPlanId,
serviceId: pathServiceId,
} = useParams<{
id: string;
employeePositionId?: string;
planId?: string;
serviceId?: string;
}>();
const [searchParams] = useSearchParams();
const urlParentId = searchParams.get("parentEmployeePlanId");
const urlEmployeePositionId = searchParams.get("employeePositionId");
const urlPlanId = searchParams.get("planId");
const urlServiceId = searchParams.get("serviceId");
const parentId = propParentId || urlParentId;
const employeePositionId = urlEmployeePositionId || pathEmployeePositionId;
const planId = urlPlanId || pathPlanId;
const serviceId = urlServiceId || pathServiceId;
const { user, selectedPositionId } = useAuth();
const localizedName = useLocalizedName();
const isEdit = !!id;
// Pick the employee record matching the globally-selected position, so a
// user with positions in multiple orgs uses the right org. Falls back to
// the first employee record when nothing is selected.
const organizationId = useMemo(() => {
const employees = user?.employee ?? [];
if (selectedPositionId) {
const match = employees.find((emp) =>
emp.positions?.some(
(p) =>
p.employeePositionId === selectedPositionId ||
p.id === selectedPositionId,
),
);
if (match?.organizationId) return match.organizationId;
}
return employees[0]?.organizationId;
}, [user, selectedPositionId]);
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const unitId = unitsQuery?.data?.data?.items?.[0]?.id ?? "";
const { data: positionsData } = usePositionsByUnit(unitId);
const { data: employeePlanDetail } = useEmployeePlan(id || "");
const { create: createMutation, update: updateMutation } =
useEmployeePlanMutations();
const { data: myPlans } = useMinePlan();
console.log("my plans ", myPlans);
//
const { data: employeePositionsData } = useEmployeePositions();
console.log("employee positoin ", employeePositionsData);
// Fetch services and parent employee plan details
const { data: servicesData } = useServices();
console.log("services ", servicesData);
// Fetch parent employee plan detail if parentId is present in URL
const { data: urlParentEmployeePlanDetail } = useEmployeePlan(parentId || "");
const normalizeTimeframe = (val: unknown): string => {
if (!val) return TimeFrame.YEAR;
const str = String(val).toLowerCase();
if (str.includes("year")) return TimeFrame.YEAR;
if (str.includes("quarter")) return TimeFrame.QUARTER;
if (str.includes("month")) return TimeFrame.MONTH;
if (str.includes("week")) return TimeFrame.WEEK;
if (str.includes("day")) return TimeFrame.DAY;
return TimeFrame.YEAR;
};
const initialEmployeePlanFormValues = useMemo(
() => ({
parentEmployeePlanId:
employeePlanDetail?.data?.parentEmployeePlanId || parentId || undefined,
employeePositionId:
employeePlanDetail?.data?.employeePositionId ||
employeePositionId ||
"",
planId: employeePlanDetail?.data?.planId || planId || "",
serviceId: employeePlanDetail?.data?.serviceId || serviceId || "",
planType: employeePlanDetail?.data?.planType || EmployeePlanType.NUMBER,
expectedQuantity: employeePlanDetail?.data?.expectedQuantity || 0,
week: employeePlanDetail?.data?.week?.toString() || "0",
month: employeePlanDetail?.data?.month?.toString() || "0",
quarter: employeePlanDetail?.data?.quarter?.toString() || "0",
timeframe: (() => {
const rawTimeframe = employeePlanDetail?.data?.timeframe;
if (rawTimeframe) return normalizeTimeframe(rawTimeframe);
// Fallback for sub-plan creation when parent data is missing but parentId is present
const parent = urlParentEmployeePlanDetail?.data;
if (!parentId || !parent) {
return parentId ? TimeFrame.QUARTER : TimeFrame.YEAR;
}
const pt = normalizeTimeframe(parent.timeframe);
if (pt === TimeFrame.YEAR) return TimeFrame.QUARTER;
if (pt === TimeFrame.QUARTER) return TimeFrame.QUARTER;
if (pt === TimeFrame.MONTH) return TimeFrame.WEEK;
if (pt === TimeFrame.WEEK) return TimeFrame.DAY;
return TimeFrame.YEAR;
})(),
}),
[
employeePlanDetail?.data,
parentId,
employeePositionId,
planId,
serviceId,
urlParentEmployeePlanDetail?.data,
]
);
const form = useForm<EmployeePlanFormValues>({
defaultValues: initialEmployeePlanFormValues,
values: isEdit || !!parentId ? initialEmployeePlanFormValues : undefined,
});
const watchedPlanId = useWatch({
control: form.control,
name: "planId",
});
const { data: parentEmployeePlanDetail } = useEmployeePlanByPlanId(
watchedPlanId || ""
);
const positionOptions =
positionsData?.data?.items?.map(
(position: { id: string; title: string }) => ({
value: position.id,
label: position.title,
icon: User,
})
) || [];
const employeePositionOptions =
employeePositionsData?.items?.flatMap((emp) =>
emp.employeePositions.map((empPos) => ({
value: empPos.id, // employeePositionId
label: `${emp.name.en} ${empPos.position?.name?.am}`,
icon: User,
}))
) || [];
// Mock data - replace with your actual data fetching
const planOptions =
myPlans?.data?.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
value: plan.id,
label: localizedName(plan.name),
icon: FileText,
})
) || [];
const serviceOptions =
servicesData?.data?.items?.map(
(service: { id: string; name: { am: string; en: string } }) => ({
value: service.id,
label: localizedName(service.name),
icon: Users,
})
) || [];
// Determine allowed timeframe options based on parent employee plan
const timeframeOptions = useMemo(() => {
// Current parent detail either from URL (parentId) or watched result
const parent =
urlParentEmployeePlanDetail?.data || parentEmployeePlanDetail?.data;
if (!parentId && !parent) {
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
];
}
const parentTimeframe = normalizeTimeframe(parent?.timeframe);
switch (parentTimeframe) {
case TimeFrame.YEAR:
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
{
value: TimeFrame.QUARTER,
label: t("employeePlans.quarterly", "Quarterly"),
},
];
case TimeFrame.QUARTER:
return [
{
value: TimeFrame.QUARTER,
label: t("employeePlans.quarterly", "Quarterly"),
},
];
case TimeFrame.MONTH:
return [
{
value: TimeFrame.WEEK,
label: t("employeePlans.weekly", "Weekly"),
},
];
case TimeFrame.WEEK:
return [
{
value: TimeFrame.DAY,
label: t("employeePlans.daily", "Daily"),
},
];
default:
return [
{
value: TimeFrame.YEAR,
label: t("employeePlans.yearly", "Yearly"),
},
];
}
}, [
parentId,
parentEmployeePlanDetail?.data,
urlParentEmployeePlanDetail?.data,
t,
]);
// Auto-select timeframe if there's only one option
useEffect(() => {
if (timeframeOptions.length === 1) {
const currentVal = form.getValues("timeframe");
if (currentVal !== timeframeOptions[0].value) {
form.setValue("timeframe", timeframeOptions[0].value as string, {
shouldValidate: true,
shouldDirty: true,
});
}
}
}, [timeframeOptions, form]);
useEffect(() => {
const current = form.getValues("timeframe");
const isValid = timeframeOptions.some((opt) => opt.value === current);
if (!isValid && timeframeOptions.length > 0) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}, [timeframeOptions, form]);
const planTypeOptions = Object.values(EmployeePlanType).map((type) => ({
value: type,
label: type.charAt(0).toUpperCase() + type.slice(1).toLowerCase(),
}));
// TODO: Fetch employee plans for parent selection
const employeePlanOptions =
parentEmployeePlanDetail?.data.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
value: plan.id,
label: localizedName(plan.name),
icon: FileText,
})
) || [];
const watchedTimeframe = useWatch({
control: form.control,
name: "timeframe",
});
// Reset hidden fields when timeframe changes
useEffect(() => {
if (watchedTimeframe === TimeFrame.YEAR) {
form.setValue("quarter", "0");
form.setValue("month", "0");
form.setValue("week", "0");
} else if (watchedTimeframe === TimeFrame.QUARTER) {
form.setValue("month", "0");
form.setValue("week", "0");
} else if (watchedTimeframe === TimeFrame.MONTH) {
form.setValue("week", "0");
}
// For WEEK and DAY, no reset needed as they inherit from parent timeframes
}, [watchedTimeframe, form]);
// Inherit context from parent employee plan
useEffect(() => {
if (parentId && parentEmployeePlanDetail?.data && !isEdit) {
const parent = parentEmployeePlanDetail.data;
// Inherit timeframe context
switch (parent.timeframe) {
case TimeFrame.YEAR:
form.setValue("timeframe", TimeFrame.QUARTER);
break;
case TimeFrame.QUARTER:
form.setValue("timeframe", TimeFrame.QUARTER);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
break;
case TimeFrame.MONTH:
form.setValue("timeframe", TimeFrame.WEEK);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
if (parent.month) form.setValue("month", parent.month.toString());
break;
case TimeFrame.WEEK:
form.setValue("timeframe", TimeFrame.DAY);
if (parent.quarter)
form.setValue("quarter", parent.quarter.toString());
if (parent.month) form.setValue("month", parent.month.toString());
if (parent.week) form.setValue("week", parent.week.toString());
break;
default:
break;
}
}
}, [parentId, parentEmployeePlanDetail, isEdit, form]);
const handleSubmit = (values: EmployeePlanFormValues) => {
// Prepare timeframe-specific fields based on selected timeframe
const getTimeframeFields = (): {
quarter: number;
month: number;
week: number;
} => {
const fields = {
quarter: 0,
month: 0,
week: 0,
};
switch (values.timeframe) {
case TimeFrame.QUARTER:
fields.quarter = Number(values.quarter);
break;
case TimeFrame.MONTH:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
break;
case TimeFrame.WEEK:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
fields.week = Number(values.week);
break;
case TimeFrame.DAY:
fields.quarter = Number(values.quarter);
fields.month = Number(values.month);
fields.week = Number(values.week);
break;
// For YEAR, all fields remain 0
}
return fields;
};
const dto = {
id: isEdit ? id : undefined,
parentEmployeePlanId: values.parentEmployeePlanId,
employeePositionId: values.employeePositionId,
planId: values.planId,
serviceId: values.serviceId,
planType: values.planType,
expectedQuantity: Number(values.expectedQuantity),
timeframe: values.timeframe,
...getTimeframeFields(),
};
const mutation = isEdit ? updateMutation : createMutation;
mutation.mutate(dto, {
onSuccess: () => {
toast.success(
isEdit
? t(
"employeePlans.planUpdatedSuccess",
"Employee plan updated successfully!"
)
: t(
"employeePlans.planCreatedSuccess",
"Employee plan created successfully!"
),
{
description: `${t("employeePlans.plan", "Employee plan")} ${
isEdit
? t("common.updated", "updated")
: t("common.created", "created")
}.`,
}
);
onSuccess?.();
if (!isEdit) {
navigate(-1);
}
},
onError: (error: { message?: string }) => {
toast.error(
isEdit
? t(
"employeePlans.planUpdateFailed",
"Failed to update employee plan"
)
: t(
"employeePlans.planCreateFailed",
"Failed to create employee plan"
),
{
description:
error?.message ||
t("common.checkInput", "Please check your input and try again."),
}
);
},
});
};
// Define form fields
const employeePlanFormFields: FormField<EmployeePlanFormValues>[] = [
// Basic Assignment Section
{
name: "employeePositionId",
label: t("employeePlans.employeePosition", "Employee Position"),
type: "select",
options:
employeePositionOptions.length > 0
? employeePositionOptions
: positionOptions,
required: true,
icon: User,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "serviceId",
label: t("employeePlans.service", "Service"),
type: "select",
options: serviceOptions,
required: true,
icon: Users,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "planId",
label: t("employeePlans.plan", "Plan"),
type: "select",
options: planOptions,
required: true,
icon: FileText,
section: t("employeePlans.assignment", "Assignment"),
colSpan: 6,
},
{
name: "parentEmployeePlanId",
label: t("employeePlans.parentEmployeePlan", "Parent Employee Plan"),
type: "select",
options: employeePlanOptions,
icon: FileText,
section: t("employeePlans.assignment", "Assignment"),
disabled: !!parentId,
colSpan: 6,
defaultValue: parentId || undefined,
},
// Plan Details Section
{
name: "planType",
label: t("employeePlans.planType", "Plan Type"),
type: "select",
options: planTypeOptions,
required: true,
icon: Target,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
{
name: "expectedQuantity",
label: t("employeePlans.expectedQuantity", "Expected Quantity"),
type: "number",
required: true,
icon: Target,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
{
name: "timeframe",
label: t("employeePlans.timeFrame", "Time Frame"),
type: "select",
options: timeframeOptions,
required: true,
disabled: timeframeOptions.length === 1,
icon: Calendar,
section: t("employeePlans.planDetails", "Plan Details"),
colSpan: 4,
},
// Timeframe Context Section
{
name: "quarter",
label: t("employeePlans.quarter", "Quarter"),
type: "number",
placeholder: t("employeePlans.enterQuarter", "Enter quarter (1-4)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
watchedTimeframe === TimeFrame.YEAR &&
(!form.getValues("quarter") ||
String(form.getValues("quarter")) === "0"),
colSpan: 4,
min: 1,
max: 4,
},
{
name: "month",
label: t("employeePlans.month", "Month"),
type: "number",
placeholder: t("employeePlans.enterMonth", "Enter month (1-12)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
![TimeFrame.MONTH, TimeFrame.WEEK, TimeFrame.DAY].includes(
watchedTimeframe as TimeFrame
) &&
(!form.getValues("month") || String(form.getValues("month")) === "0"),
colSpan: 4,
min: 1,
max: 12,
},
{
name: "week",
label: t("employeePlans.week", "Week"),
type: "number",
placeholder: t("employeePlans.enterWeek", "Enter week (1-52)"),
icon: Calendar,
section: t("employeePlans.timeframeContext", "Timeframe Context"),
hidden:
![TimeFrame.WEEK, TimeFrame.DAY].includes(
watchedTimeframe as TimeFrame
) &&
(!form.getValues("week") || String(form.getValues("week")) === "0"),
colSpan: 4,
min: 1,
max: 52,
},
];
// Filter fields based on timeframe
const filteredFields = employeePlanFormFields.filter((field) => {
if (field.hidden) return false;
return true;
});
const handleCancel = () => {
navigate(-1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 text-gray-700 hover:bg-gray-50">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{isEdit ? "Edit Employee Plan" : "Create Employee Plan"}
</h1>
<p className="text-gray-600 mt-1">
{isEdit
? "Update employee plan assignment and details"
: "Assign a plan to an employee with specific targets"}
</p>
</div>
</div>
<div className="flex items-center space-x-2 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div
className={`w-2 h-2 rounded-full ${
isEdit ? "bg-amber-500" : "bg-green-500"
} animate-pulse`}></div>
<span className="text-sm font-medium text-gray-700">
{isEdit ? "Editing Mode" : "Creation Mode"}
</span>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}>
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-xl overflow-hidden">
<CardHeader className="bg-gradient-to-r from-blue-500 to-cyan-500 text-white pb-6">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
{isEdit ? (
<FileText className="h-6 w-6 text-white" />
) : (
<Plus className="h-6 w-6 text-white" />
)}
</div>
<div>
<CardTitle className="text-2xl font-bold text-white">
{isEdit
? t(
"employeePlans.updateEmployeePlan",
"Update Employee Plan"
)
: t(
"employeePlans.newEmployeePlanAssignment",
"New Employee Plan Assignment"
)}
</CardTitle>
<CardDescription className="text-blue-100">
{isEdit
? t(
"employeePlans.modifyPlanDetails",
"Modify the employee plan details below"
)
: t(
"employeePlans.assignPlanWithTimeframe",
"Assign plan to employee with specific timeframe and targets"
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-6">
{/* Parent Plan Info Alert */}
{parentId && parentEmployeePlanDetail?.data && (
<Alert className="mb-6 bg-blue-50/80 border-blue-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 font-semibold">
{t(
"employeePlans.creatingSubEmployeePlan",
"Creating Sub-Employee Plan"
)}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t(
"employeePlans.subPlanUnder",
"This employee plan will be created under"
)}
:{" "}
<strong className="text-blue-900">
{parentEmployeePlanDetail.data.name ||
t("employeePlans.parentPlan", "Parent Plan")}
</strong>
</AlertDescription>
</Alert>
)}
<GenericForm
form={form}
fields={filteredFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createMutation.isPending || updateMutation.isPending
}
submitButtonText={
isEdit
? t(
"employeePlans.updateEmployeePlan",
"Update Employee Plan"
)
: t(
"employeePlans.createEmployeePlan",
"Create Employee Plan"
)
}
cancelButtonText={t("employeePlans.cancel", "Cancel")}
successMessage={
isEdit
? t(
"employeePlans.planUpdatedSuccess",
"Employee plan updated successfully!"
)
: t(
"employeePlans.planCreatedSuccess",
"Employee plan created successfully!"
)
}
errorMessage={
isEdit
? t(
"employeePlans.planUpdateFailed",
"Failed to update employee plan"
)
: t(
"employeePlans.planCreateFailed",
"Failed to create employee plan"
)
}
/>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useState, useEffect } from "react";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Loader2, ArrowLeft } from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
updateEmployeePlan,
getEmployeePlan,
getPlansByUnit,
} from "@/performance-management/services/api/employeePlanService";
const EditEmployeePlanPage = () => {
const { id } = useParams<{ id: string }>();
const { toast } = useToast();
const { t } = useTranslation();
const navigate = useNavigate();
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingPlans, setIsLoadingPlans] = useState(false);
const [plans, setPlans] = useState<any[]>([]);
const [formData, setFormData] = useState({
planId: "",
employeePositionId: "",
serviceId: "",
timeframe: "",
expectedQuantity: 0,
planType: "number" as "number" | "percent",
});
// Fetch employee plan data when component mounts
useEffect(() => {
if (id) {
fetchEmployeePlan(id);
fetchPlans();
}
}, [id]);
const fetchEmployeePlan = async (planId: string) => {
setIsLoading(true);
try {
const response = await getEmployeePlan(planId);
const plan = response.data;
// Convert ISO dates to YYYY-MM-DD format for input fields
setFormData({
planId: plan.planId || "",
employeePositionId: plan.employeePositionId || "",
serviceId: plan.serviceId || "",
timeframe: plan.timeframe || "",
expectedQuantity: plan.expectedQuantity || 0,
planType: plan.planType || "number",
});
} catch (error) {
console.error("Failed to fetch employee plan:", error);
toast({
title: "Error",
description: "Failed to fetch employee plan data",
variant: "destructive",
});
navigate("/performance-management/employee-plans");
} finally {
setIsLoading(false);
}
};
const fetchPlans = async () => {
setIsLoadingPlans(true);
try {
const response = await getPlansByUnit("");
const plansData = response.data?.items || [];
setPlans(Array.isArray(plansData) ? plansData : []);
} catch (error) {
console.error("Failed to fetch plans:", error);
toast({
title: "Error",
description: "Failed to fetch plans",
variant: "destructive",
});
} finally {
setIsLoadingPlans(false);
}
};
const handleChange = (field: string, value: any) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) {
toast({
title: "Error",
description: "Employee plan ID is missing",
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await updateEmployeePlan(id, {
parentEmployeePlanId: formData.planId,
employeePositionId: formData.employeePositionId,
planId: formData.planId,
serviceId: formData.serviceId,
planType: formData.planType,
expectedQuantity: Number(formData.expectedQuantity),
timeframe: formData.timeframe,
});
toast({
title: "Success",
description: "Employee plan updated successfully",
variant: "default",
});
// Navigate back to list
navigate("/performance-management/employee-plans");
} catch (error: any) {
console.error("Failed to update employee plan:", error);
const errorMessage =
error.response?.data?.message ||
error.message ||
"Failed to update employee plan";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<div className="p-6 flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
);
}
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<Button
variant="ghost"
onClick={() => navigate("/performance-management/employee-plans")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back", "Back")}
</Button>
<h1 className="text-2xl font-bold text-gray-900">
{t("employeePlans.editPlan", "Edit Employee Plan")}
</h1>
<p className="text-gray-600 mt-1">
{t(
"employeePlans.editDescription",
"Update the details of this employee performance plan"
)}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>
{t("employeePlans.planDetails", "Plan Details")}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Plan Selection */}
<div className="space-y-2">
<Label htmlFor="planId">
{t("employeePlans.plan", "Plan")}
<span className="text-red-500">*</span>
</Label>
{isLoadingPlans ? (
<div className="flex items-center gap-2 text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading", "Loading plans...")}
</div>
) : plans.length > 0 ? (
<Select
value={formData.planId}
onValueChange={(value) => handleChange("planId", value)}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans.map((plan: any) => (
<SelectItem key={plan.id} value={plan.id}>
{typeof plan.name === "string"
? plan.name
: plan.name?.en || plan.name?.am || plan.id}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-gray-500">
{t("employeePlans.noPlans", "No plans available")}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Start Date */}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Expected Quantity */}
<div className="space-y-2">
<Label htmlFor="expectedQuantity">
{t("employeePlans.expectedQuantity", "Expected Quantity")}
</Label>
<Input
id="expectedQuantity"
type="number"
min="0"
step="0.01"
value={formData.expectedQuantity}
onChange={(e) =>
handleChange(
"expectedQuantity",
parseFloat(e.target.value) || 0
)
}
placeholder="Enter expected quantity"
/>
</div>
{/* Plan Type */}
<div className="space-y-2">
<Label htmlFor="planType">
{t("employeePlans.planType", "Plan Type")}
</Label>
<Select
value={formData.planType}
onValueChange={(value) => handleChange("planType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select plan type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="number">
{t("employeePlans.number", "Number")}
</SelectItem>
<SelectItem value="percent">
{t("employeePlans.percentage", "Percent")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() =>
navigate("/performance-management/employee-plans")
}
disabled={isSaving}
>
{t("common.cancel", "Cancel")}
</Button>
<Button
type="submit"
disabled={isSaving}
className="bg-purple-600 hover:bg-purple-700"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("saving", "Saving...")}
</>
) : (
t("employeePlans.update", "Update Plan")
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default EditEmployeePlanPage;

View File

@@ -0,0 +1,203 @@
import { Users, AlertTriangle, CheckCircle2 } from "lucide-react";
import { useQueries, useQuery } from "@tanstack/react-query";
import {
getChildPositions,
getEmployeesUnderPosition,
} from "@/user-management/services/api/positionService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Avatar, AvatarFallback } from "@/shared/common/ui/avatar";
import { Badge } from "@/shared/common/ui/badge";
import type { PositionDto } from "@/user-management/dto/positions/positionDto";
/**
* Direct reports of the currently-active position.
*
* 1. GET /positions/children/:myPositionId → direct child positions
* (each item has parentPositionId === myPositionId)
* 2. For each child position, GET /positions/current/:childPositionId/employees
* → employees holding that position
*
* This bypasses the buggy /employees/immediate-child endpoint which was
* returning peers (rows sharing the same position_id) instead of children.
*/
export const MyTeamPanel = () => {
const { user, selectedPositionId } = useAuth();
const localizedName = useLocalizedName();
// Resolve the active position so we can use its `id` (not employeePositionId).
const allPositions =
user?.employee?.flatMap((emp) => emp.positions ?? []) ?? [];
const activePosition =
allPositions.find(
(p) =>
p.employeePositionId === selectedPositionId ||
p.id === selectedPositionId,
) ?? allPositions[0];
const myPositionId = activePosition?.id;
const {
data: childrenResp,
isLoading: childrenLoading,
error: childrenError,
} = useQuery({
queryKey: ["positionChildren", myPositionId],
queryFn: () => getChildPositions(myPositionId!),
enabled: !!myPositionId,
});
const childPositions: PositionDto[] = childrenResp?.data?.items ?? [];
// Fetch employees per direct-child position in parallel.
const employeeQueries = useQueries({
queries: childPositions.map((cp) => ({
queryKey: ["positionEmployees", cp.id],
queryFn: () => getEmployeesUnderPosition(cp.id),
enabled: !!cp.id,
})),
});
if (childrenLoading) {
return <Skeleton className="h-32 w-full rounded-xl mb-6" />;
}
if (childrenError) {
return (
<Card className="mb-6 border-red-200 bg-red-50">
<CardContent className="p-4 text-sm text-red-700">
Failed to load child positions:{" "}
{(childrenError as Error).message}
</CardContent>
</Card>
);
}
const totalEmployees = employeeQueries.reduce(
(sum, q) => sum + ((q.data?.data?.items?.length ?? q.data?.data?.length) || 0),
0,
);
return (
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-5 w-5" />
My Team
<Badge variant="secondary" className="ml-1">
{totalEmployees}
</Badge>
</CardTitle>
<div className="flex flex-wrap gap-2 text-xs mt-2 text-gray-500">
<span>
Acting as position id: <code>{myPositionId ?? "—"}</code>
</span>
<span>·</span>
<span>
{childPositions.length} direct child position
{childPositions.length === 1 ? "" : "s"}
</span>
</div>
</CardHeader>
<CardContent>
{childPositions.length === 0 ? (
<p className="text-sm text-gray-500">
No child positions exist under your current position.
</p>
) : (
<div className="space-y-4">
{childPositions.map((cp, idx) => {
const empQuery = employeeQueries[idx];
const employeesRaw =
empQuery?.data?.data?.items ?? empQuery?.data?.data ?? [];
const employees = Array.isArray(employeesRaw) ? employeesRaw : [];
// Sanity-check: every returned position should have
// parentPositionId === myPositionId. Anything else is suspicious.
const isCorrectChild = cp.parentPositionId === myPositionId;
return (
<div
key={cp.id}
className={`rounded-xl border p-3 ${
isCorrectChild
? "bg-white"
: "bg-amber-50 border-amber-200"
}`}
>
<div className="flex items-center gap-2 mb-3">
<Badge variant="outline">
{localizedName(cp.name) || cp.key}
</Badge>
{!isCorrectChild && (
<span
className="inline-flex items-center gap-1 text-xs text-amber-700"
title={`Expected parentPositionId=${myPositionId}, got ${cp.parentPositionId}`}
>
<AlertTriangle className="h-3 w-3" /> wrong parent
</span>
)}
<span className="text-xs text-gray-500 ml-auto">
{empQuery?.isLoading
? "loading…"
: `${employees.length} employee${employees.length === 1 ? "" : "s"}`}
</span>
</div>
{empQuery?.error ? (
<p className="text-xs text-red-600">
Failed to load employees for this position
</p>
) : employees.length === 0 && !empQuery?.isLoading ? (
<p className="text-xs text-gray-500">
No employees currently hold this position.
</p>
) : (
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{employees.map((emp: any) => {
const name = localizedName(emp.name);
const initials = name
?.split(" ")
.map((s: string) => s[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<li
key={emp.id}
className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-sm"
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{initials || "?"}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="font-medium truncate text-xs">
{name}
</p>
</div>
<CheckCircle2 className="h-3 w-3 text-green-500 ml-auto" />
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,693 @@
import { useState, useEffect } from "react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/shared/common/ui/button";
import {
Loader2,
Target,
Hash,
Percent,
ArrowLeft,
Save,
Check,
CalendarDays,
Grid,
List,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import { useToast } from "@/shared/common/ui/use-toast";
import { Badge } from "@/shared/common/ui/badge";
import {
ExtendedEmployeePlanDto,
useEmployeePlanMutations,
useSubEmployeePlanByPlanId,
} from "@/performance-management/hooks/useEmployeePlan";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@/shared/common/ui/tabs";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { cn } from "@/shared/lib/utils";
// Interface for the data passed via navigation state
interface NavigationStateData {
parentEmployeePlanId: string;
employeePositionId: string;
planId: string;
serviceId: string;
planType: "number" | "percent" | string;
currentQuantity: number;
expectedQuantity: number;
month: number;
timeframe: string;
planName: string;
serviceName: string;
weight: number;
status: string;
}
// Week data interface
interface WeekData {
weekNumber: number;
planned: number;
employeePlanId: string | null;
}
const WeeklyPlanAssignmentPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { yearId, monthId } = useParams();
const { toast } = useToast();
const { handleError } = useErrorHandler(t);
const [activeTab, setActiveTab] = useState<"table" | "calendar">("table");
const [isLoadingExistingPlans, setIsLoadingExistingPlans] = useState(false);
const queryClient = useQueryClient();
const { create, update } = useEmployeePlanMutations();
// Get data from navigation state
const navigationState = location.state as NavigationStateData;
// Destructure all necessary data from navigation state
const {
parentEmployeePlanId,
employeePositionId,
planId,
serviceId,
planType: initialPlanType,
expectedQuantity,
planName,
serviceName,
status,
month: monthFromPlan,
} = navigationState;
// Get month from URL param or plan data
const monthFromUrl = monthId ? parseInt(monthId) : monthFromPlan;
const [weeks, setWeeks] = useState<WeekData[]>([
{ weekNumber: 1, planned: 0, employeePlanId: null },
{ weekNumber: 2, planned: 0, employeePlanId: null },
{ weekNumber: 3, planned: 0, employeePlanId: null },
{ weekNumber: 4, planned: 0, employeePlanId: null },
]);
const [selectedPlanType, setSelectedPlanType] = useState<
"number" | "percent"
>(initialPlanType === "percent" ? "percent" : "number");
const [isSaving, setIsSaving] = useState(false);
const { data: existingPlanData, isLoading: isPlanLoading } =
useSubEmployeePlanByPlanId(navigationState.parentEmployeePlanId);
// Fetch existing weekly plans for this month.
// API shape:
// { "<monthNumber>": { "week_1": { id, quantity }, "week_2": { id, quantity }, ... } }
useEffect(() => {
if (!existingPlanData?.data || !monthFromUrl) return;
const monthData = existingPlanData.data[monthFromUrl.toString()];
if (!monthData) return;
// Build the weeks list dynamically so we render all weeks the API knows
// about (some months have 5 weeks, others 4).
const weekEntries = Object.keys(monthData)
.filter((k) => k.startsWith("week_"))
.map((k) => {
const weekNumber = parseInt(k.replace("week_", ""), 10);
const cell = monthData[k] as
| { id: string | null; quantity: number }
| number
| null
| undefined;
// Defensive: handle both the new shape ({ id, quantity }) and any
// legacy shape that may have returned a bare number.
if (cell && typeof cell === "object") {
return {
weekNumber,
planned: Number(cell.quantity) || 0,
employeePlanId: cell.id ?? null,
};
}
return {
weekNumber,
planned: typeof cell === "number" ? cell : 0,
employeePlanId: null,
};
})
.sort((a, b) => a.weekNumber - b.weekNumber);
if (weekEntries.length > 0) {
setWeeks(weekEntries);
}
}, [existingPlanData, monthFromUrl]);
// Sync loading state
useEffect(() => {
setIsLoadingExistingPlans(isPlanLoading);
}, [isPlanLoading]);
// Calculate total planned
const totalPlanned = weeks.reduce((sum, week) => sum + week.planned, 0);
// Update a week's value in local state
const updateWeekValue = (weekNumber: number, value: string) => {
const newValue = parseInt(value) || 0;
setWeeks((prevWeeks) =>
prevWeeks.map((w) =>
w.weekNumber === weekNumber ? { ...w, planned: newValue } : w,
),
);
};
// Quick set helper
const quickSetForAllWeeks = (value: number) => {
setWeeks((prevWeeks) => prevWeeks.map((w) => ({ ...w, planned: value })));
};
// Save all changes
const handleSaveAll = async () => {
if (!planId || !monthFromUrl) return;
// Check if plan is already accepted
if (status === "accepted") {
toast({
title: t("plans.planAlreadyAccepted", "Plan Already Accepted"),
description: t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
),
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
// Run sequentially so each create's returned id can be threaded into the
// next iteration's local state — prevents a duplicate-key error on a
// second save attempt where the previous create succeeded but the local
// state still shows employeePlanId = null.
for (const week of weeks) {
const employeePlanData: ExtendedEmployeePlanDto = {
employeePositionId,
planId,
serviceId,
planType: selectedPlanType === "number" ? "number" : selectedPlanType,
expectedQuantity: week.planned,
timeframe: "weekly",
week: week.weekNumber,
month: monthFromUrl,
parentEmployeePlanId,
};
try {
if (week.employeePlanId) {
await update.mutateAsync({
...employeePlanData,
id: week.employeePlanId,
});
} else {
const response = await create.mutateAsync(employeePlanData);
const newId =
(response as any)?.data?.id ?? (response as any)?.id;
if (newId) {
// Persist the new id so subsequent saves PUT instead of POST.
setWeeks((prev) =>
prev.map((w) =>
w.weekNumber === week.weekNumber
? { ...w, employeePlanId: newId }
: w,
),
);
week.employeePlanId = newId;
}
}
} catch (err: any) {
// 400 with duplicate-key constraint → the row already exists in the
// DB even though our local state didn't have its id. Refetch and
// bail out of this iteration; the user can re-save and the latest
// ids will be in state.
const message: string =
err?.response?.data?.message ?? err?.message ?? "";
const isDuplicate =
err?.response?.status === 400 &&
(message.toLowerCase().includes("duplicate") ||
err?.response?.data?.exception?.driverError?.code === "23505");
if (isDuplicate) {
await queryClient.invalidateQueries({
queryKey: ["employee-plans", planId],
});
throw new Error(
"This week already has a saved plan. Refreshing — please try save again.",
);
}
throw err;
}
}
queryClient.invalidateQueries({
queryKey: ["employee-plans", planId],
});
toast({
title: "Success",
description: "All weekly targets have been saved successfully.",
});
} catch (error) {
console.error("Failed to save plans:", error);
handleError(error);
} finally {
setIsSaving(false);
}
};
// Get month name
const getMonthName = (monthNum: number): string => {
const monthNames = [
"September",
"October",
"November",
"December",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
];
return monthNames[monthNum - 1] || `Month ${monthNum}`;
};
if (!navigationState) {
return (
<div className="p-6">
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6 text-center">
<h3 className="text-lg font-semibold text-red-700">
Missing Plan Data
</h3>
<p className="text-red-600 mt-2">
Please go back and select a plan to continue.
</p>
<Button
onClick={() =>
navigate(`/performance-management/${yearId}/employee-plans`)
}
className="mt-4"
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/performance-management/${yearId}/employee-plans`)
}
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Plans
</Button>
<div>
<h1 className="text-2xl font-bold text-gray-900">
Weekly Plan Assignment - {planName}
</h1>
<p className="text-gray-600 mt-1">
Set weekly targets for {getMonthName(monthFromUrl)} (4 weeks)
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={handleSaveAll}
disabled={
isSaving ||
create.isPending ||
update.isPending ||
status === "accepted"
}
className="bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white shadow-lg"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</div>
{/* Alert for Accepted Plans */}
{status === "accepted" && (
<Card className="mb-6 border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-start gap-4">
<div className="text-red-600 mt-0.5">
<svg
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="font-semibold text-red-900">
{t("plans.planAlreadyAccepted", "Plan Already Accepted")}
</h3>
<p className="text-red-700 text-sm mt-1">
{t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
{[
{
title: "Service",
value: serviceName,
icon: Target,
color: "blue",
},
{
title: "Monthly Target",
value: expectedQuantity,
icon: selectedPlanType === "number" ? Hash : Percent,
color: "green",
subtitle: selectedPlanType === "number" ? "Number" : "Number",
},
{
title: "Current Total",
value: totalPlanned,
icon: Target,
color: "purple",
},
{
title: "Month",
value: getMonthName(monthFromUrl),
icon: CalendarDays,
color: "red",
subtitle: `${weeks.length} weeks`,
},
].map((card, idx) => (
<Card key={idx}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
{card.title}
</p>
<p className="text-2xl font-bold">{card.value}</p>
{card.subtitle && (
<p className="text-xs text-gray-500 mt-1">
{card.subtitle}
</p>
)}
</div>
<div className={`p-2 bg-${card.color}-100 rounded-lg`}>
<card.icon className={`h-6 w-6 text-${card.color}-600`} />
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* View Toggle */}
<Card className="mb-6">
<CardContent className="pt-6">
<div className="flex flex-col space-y-4">
<Tabs
value={activeTab}
onValueChange={(v: string) =>
setActiveTab(v as "table" | "calendar")
}
>
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="table" className="flex items-center gap-2">
<List className="h-4 w-4" /> Table View
</TabsTrigger>
<TabsTrigger
value="calendar"
className="flex items-center gap-2"
>
<Grid className="h-4 w-4" /> Calendar View
</TabsTrigger>
</TabsList>
</Tabs>
{/* Plan Type Selection */}
<div className="flex items-center gap-4">
<div>
<Label>Plan Type</Label>
<div className="flex gap-2 mt-1">
<Button
variant={
selectedPlanType === "number" ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedPlanType("number")}
className="gap-2"
>
<Hash className="h-4 w-4" /> Number
</Button>
<Button
variant={
selectedPlanType === "number" ? "default" : "outline"
}
size="sm"
onClick={() => setSelectedPlanType("number")}
className="gap-2"
>
<Percent className="h-4 w-4" /> Percent
</Button>
</div>
</div>
<div className="ml-auto">
<div className="flex gap-2 items-center">
<p className="text-sm text-gray-500 mr-2">Quick Set:</p>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(10)}
>
10
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(25)}
>
25
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(50)}
>
50
</Button>
<Button
variant="outline"
size="sm"
onClick={() => quickSetForAllWeeks(0)}
>
Clear
</Button>
</div>
</div>
</div>
{/* Month Info */}
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-900">
{getMonthName(monthFromUrl)} Weekly Planning
</h4>
<p className="text-sm text-gray-600">
Set weekly targets below. Don't forget to save your changes.
</p>
</div>
<Badge
variant={status === "accepted" ? "default" : "secondary"}
>
Status: {status}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Loading State */}
{isLoadingExistingPlans ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
<span className="ml-2 text-gray-600">
Loading existing weekly plans...
</span>
</div>
) : activeTab === "table" ? (
<Card className="overflow-hidden">
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle>
Weekly Targets for {getMonthName(monthFromUrl)}
</CardTitle>
<CardDescription>
Enter targets for each week directly in the input fields.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-medium">
{selectedPlanType === "number" ? "Units" : "Percent"}
</Badge>
<Badge className="bg-blue-100 text-blue-800 border-blue-200">
Total Planned: {totalPlanned}
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]">Week</TableHead>
<TableHead className="text-center">Week 1</TableHead>
<TableHead className="text-center">Week 2</TableHead>
<TableHead className="text-center">Week 3</TableHead>
<TableHead className="text-center">Week 4</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-gray-50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-purple-600 font-medium">M</span>
</div>
<div>
<p className="font-medium text-gray-900">
My Weekly Targets
</p>
<p className="text-sm text-gray-500">
Month {monthFromUrl}
</p>
</div>
</div>
</TableCell>
{/* Weekly Cells */}
{weeks.map((week) => (
<TableCell
key={week.weekNumber}
className="text-center p-2"
>
<Input
type="number"
min="0"
value={week.planned || ""}
onChange={(e) =>
updateWeekValue(week.weekNumber, e.target.value)
}
className={cn(
"h-10 w-24 text-center mx-auto transition-all",
week.planned > 0
? "border-purple-300 bg-purple-50 font-semibold text-purple-900"
: "border-gray-200 text-gray-500",
)}
/>
</TableCell>
))}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2 text-sm text-gray-500">
{totalPlanned > 0 ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<div className="h-4 w-4" />
)}
<span>Total: {totalPlanned}</span>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Calendar View</CardTitle>
<CardDescription>
Coming soon: Interactive calendar view for weekly planning
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<CalendarDays className="h-12 w-12 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900">
Calendar View in Development
</h3>
<p className="text-gray-600 mt-2">
This feature will be available in the next update.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
};
export default WeeklyPlanAssignmentPage;

View File

@@ -0,0 +1,207 @@
import React, { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useMetabaseDashboard } from "../hooks/useMetabaseDashboard";
import { usePlanYear } from "../hooks/usePlanYear";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
import { DashboardDropdown } from "@/shared/components/ui/DashboardDropdown";
import {
RefreshCw,
BarChart3,
AlertCircle,
ExternalLink,
Loader2,
Calendar,
} from "lucide-react";
import { useLocalizedName } from "@/shared/common/localizedName";
import { getFormattedDashboardLabel } from "../utils/dashboardUtils";
interface MetabaseDashboardProps {
className?: string;
planYearId?: string;
}
export const MetabaseDashboard: React.FC<MetabaseDashboardProps> = ({
className = "",
planYearId,
}) => {
const { yearId } = useParams<{ yearId: string }>();
const currentPlanYearId = planYearId || yearId;
const localizedName = useLocalizedName();
const {
dashboards,
selectedDashboard,
iframeUrl,
loading,
error,
fetchDashboards,
selectDashboard,
getFilteredDashboardUrl,
} = useMetabaseDashboard();
// Fetch plan year details if we have a plan year ID
const { data: planYearData } = usePlanYear(currentPlanYearId || "");
const planYear = planYearData?.data;
const handleRefresh = () => {
if (selectedDashboard && currentPlanYearId) {
getFilteredDashboardUrl(selectedDashboard.id, currentPlanYearId);
} else {
fetchDashboards();
}
};
// Auto-load filtered dashboard when plan year or selected dashboard changes
useEffect(() => {
if (selectedDashboard && currentPlanYearId) {
getFilteredDashboardUrl(selectedDashboard.id, currentPlanYearId);
}
}, [selectedDashboard, currentPlanYearId, getFilteredDashboardUrl]);
const openInNewTab = () => {
if (iframeUrl) {
window.open(iframeUrl, "_blank");
}
};
if (error) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
Dashboard Error
</CardTitle>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
<Button onClick={handleRefresh} className="mt-4" variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</CardContent>
</Card>
);
}
return (
<div className={`space-y-4 ${className}`}>
{/* Dashboard Header */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-3">
<BarChart3 className="h-6 w-6 text-indigo-600" />
<div>
<p className="text-sm text-muted-foreground mt-1">
{planYear
? `${planYear.year} - ${localizedName(
planYear.name
)} Performance Insights`
: "Real-time performance insights powered by Tria"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DashboardDropdown
dashboards={dashboards}
selectedDashboard={selectedDashboard}
onSelect={selectDashboard}
isLoading={loading}
/>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
{iframeUrl && (
<Button
variant="outline"
size="icon"
onClick={openInNewTab}
title="Open in new tab"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{planYear && (
<Badge variant="secondary" className="bg-green-50 text-primary-700">
<Calendar className="h-3 w-3 mr-1" />
{planYear.year} - {localizedName(planYear.name)}
</Badge>
)}
{selectedDashboard && (
<>
<Badge
variant="secondary"
className="bg-indigo-50 text-indigo-700"
>
{getFormattedDashboardLabel(selectedDashboard.name)}
</Badge>
</>
)}
</div>
</CardHeader>
</Card>
{/* Dashboard Content */}
<Card className="min-h-[600px]">
<CardContent className="p-0">
{loading && !iframeUrl ? (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-indigo-600" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
) : iframeUrl ? (
<div className="relative w-full h-[600px] rounded-lg overflow-hidden">
<iframe
src={iframeUrl}
className="w-full h-full border-0"
title={`${getFormattedDashboardLabel(selectedDashboard?.name) || "Performance"} Dashboard`}
allow="fullscreen"
loading="lazy"
/>
</div>
) : (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{dashboards.length === 0
? "No dashboards available"
: "Select a dashboard to view analytics"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { MetabaseDashboard } from "./MetabaseDashboard";
export const PerformanceDashboardPage = () => {
const { yearId } = useParams<{ yearId: string }>();
const { t } = useTranslation();
return (
<div className="p-4 md:p-6 space-y-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-800">
<span className="bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{t(
"performanceDashboard.title",
"Performance Management Dashboard",
)}
</span>
</h1>
<p className="text-muted-foreground text-sm mt-1">
{t(
"performanceDashboard.description",
"Comprehensive performance analytics and insights",
)}
</p>
</div>
</div>
<MetabaseDashboard planYearId={yearId} />
</div>
);
};

View File

@@ -0,0 +1,902 @@
import { useForm, useWatch } from "react-hook-form";
import { motion } from "framer-motion";
import { Calendar, Target, FileText, ArrowLeft } from "lucide-react";
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
usePlan,
usePlanMutations,
} from "@/performance-management/hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import { usePositions } from "@/user-management/hooks/usePosition";
import {
usePlanYears,
usePlanYearById,
} from "@/performance-management/hooks/usePlanYear";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/shared/common/ui/alert";
import { Info } from "lucide-react";
import { GenericForm } from "@/performance-management/utils/generic-form"; // Adjust import path as needed
import PlanType, {
getDayList,
getMonthList,
getQuarterList,
getWeekList,
getPlanTypeOptions,
} from "@/performance-management/utils/plan-unit";
import {
FormField,
TimeFrame,
} from "@/performance-management/utils/shared-field-types";
export interface PlanFormValues {
nameAm: string;
nameEn: string;
descriptionAm: string;
descriptionEn: string;
positionId: string;
parentPlanId?: string;
planType: PlanType;
planYearId: string;
// initialGoal: string;
// expectedGoal: string;
weight: string;
month: string | number;
week: string | number;
day: string | number;
quarter: string | number;
timeframe: string | number;
planFor?: string;
}
// Removed local TimeFrame enum definition
interface PlanFormProps {
parentPlanId?: string;
onSuccess?: () => void;
}
export default function PlanForm({
parentPlanId: propParentId,
onSuccess,
}: PlanFormProps = {}) {
const navigate = useNavigate();
const { t } = useTranslation();
const {
id,
yearId: pathYearId,
positionId,
planId,
} = useParams<{
id: string;
yearId?: string;
positionId?: string;
planId?: string;
}>();
console.log("positon id", positionId);
const [searchParams] = useSearchParams();
const urlParentId =
searchParams.get("parentPlanId") || searchParams.get("parentId");
const queryYearId = searchParams.get("yearId");
// Get yearId from query params first (UUID), then fallback to path params
const urlYearId = queryYearId || pathYearId;
const location = useLocation();
const planFor = location.state?.planFor as string | undefined;
console.log("plan for in form", planFor);
const parentId =
propParentId || urlParentId || (planId === "root" ? undefined : planId);
const { user } = useAuth();
const localizedName = useLocalizedName();
const isEdit = !!id;
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const unitId = unitsQuery?.data?.data?.items?.[0]?.id ?? "";
// Fetch the specific plan being edited
const { data: planDetail } = usePlan(id ?? "");
// Fetch parent plan data if parentId is present
const { data: parentPlanDetail } = usePlan(parentId ?? "");
const { usePositionListByUnitId } = usePositions();
usePositionListByUnitId(unitId, { take: 100, skip: 0 });
const { data: planYearsData } = usePlanYears(organizationId ?? "", {
take: 100,
});
// Fetch specific plan year if yearId is provided in URL
const { data: currentPlanYearData } = usePlanYearById(urlYearId ?? "");
const { create: createMutation, update: updateMutation } = usePlanMutations();
const normalizeTimeframe = (val: unknown): string => {
if (!val) return TimeFrame.YEAR;
const str = String(val).toLowerCase();
if (str.includes("year")) return TimeFrame.YEAR;
if (str.includes("quarter")) return TimeFrame.QUARTER;
if (str.includes("month")) return TimeFrame.MONTH;
if (str.includes("week")) return TimeFrame.WEEK;
if (str.includes("day")) return TimeFrame.DAY;
return TimeFrame.YEAR;
};
const initialPlanFormValues = useMemo(
() => ({
nameAm: planDetail?.data?.name?.am || "",
nameEn: planDetail?.data?.name?.en || "",
descriptionAm: planDetail?.data?.description?.am || "",
descriptionEn: planDetail?.data?.description?.en || "",
positionId: positionId || "",
parentPlanId:
planDetail?.data?.parentPlanId || parentId || planId || undefined,
planType: planDetail?.data?.planType || "percent",
planYearId: planDetail?.data?.planYearId || urlYearId || "",
// initialGoal: planDetail?.data?.initialGoal?.toString() || "",
// expectedGoal: planDetail?.data?.expectedGoal?.toString() || "",
weight: planDetail?.data?.weight?.toString() || "",
month: planDetail?.data?.month?.toString() || "0",
week: planDetail?.data?.week?.toString() || "0",
day: planDetail?.data?.day?.toString() || "0",
quarter: planDetail?.data?.quarter?.toString() || "0",
timeframe: (() => {
const rawTimeframe = planDetail?.data?.timeframe;
if (rawTimeframe) return normalizeTimeframe(rawTimeframe);
// Fallback for sub-plan creation when parent data is missing but parentId is present
if (!parentId || !parentPlanDetail?.data) {
return parentId ? TimeFrame.QUARTER : TimeFrame.YEAR;
}
const pt = normalizeTimeframe(parentPlanDetail.data.timeframe);
if (pt === TimeFrame.YEAR) return TimeFrame.QUARTER;
if (pt === TimeFrame.QUARTER) return TimeFrame.QUARTER;
if (pt === TimeFrame.MONTH) return TimeFrame.WEEK;
if (pt === TimeFrame.WEEK) return TimeFrame.DAY;
return TimeFrame.YEAR;
})(),
}),
[
planDetail?.data,
positionId,
parentId,
planId,
urlYearId,
parentPlanDetail?.data,
],
);
const form = useForm<PlanFormValues>({
defaultValues: initialPlanFormValues,
values: isEdit || !!parentId ? initialPlanFormValues : undefined,
});
const watchedParentPlanId = useWatch({
control: form.control,
name: "parentPlanId",
});
const { data: watchedParentPlanDetail } = usePlan(
watchedParentPlanId && watchedParentPlanId !== "root"
? watchedParentPlanId
: "",
);
const effectiveParentDetail = watchedParentPlanDetail || parentPlanDetail;
const planOptions =
planDetail?.data?.items?.map(
(plan: { id: string; name: { am: string; en: string } }) => ({
label: localizedName(plan.name),
value: plan.id,
icon: FileText,
}),
) || [];
// Add parent plan to options if it exists and isn't already in the list
if (parentId && parentPlanDetail?.data) {
const parentPlanExists = planOptions.some(
(option: { label: string; value: string }) => option.value === parentId,
);
if (!parentPlanExists) {
planOptions.unshift({
label: localizedName(parentPlanDetail.data.name),
value: parentId,
icon: FileText,
});
}
}
// If yearId is in URL, show only that year; otherwise show all years
const planYearOptions =
urlYearId && currentPlanYearData?.data
? [
{
value: currentPlanYearData.data.id,
label: `${currentPlanYearData.data.year}`,
icon: Calendar,
},
]
: planYearsData?.data?.items?.map(
(year: { id: string; year: number }) => ({
value: year.id,
label: `${year.year}`,
icon: Calendar,
}),
) || [];
const planTypeOptions = useMemo(() => getPlanTypeOptions(), []);
const timeframeOptions = useMemo(() => {
const parent = effectiveParentDetail?.data;
// If no parent plan, only allow Yearly
if (!parentId && !parent && !watchedParentPlanId) {
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly"),
},
];
}
const parentTimeframe = normalizeTimeframe(parent?.timeframe);
// Based on parent timeframe, allow the next level down
switch (parentTimeframe) {
case TimeFrame.YEAR:
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly", "Yearly"),
},
{
value: TimeFrame.QUARTER,
label: t("plans.Quarterly"),
},
];
case TimeFrame.QUARTER:
return [
{
value: TimeFrame.QUARTER,
label: t("plans.Quarterly", "Quarterly"),
},
];
case TimeFrame.MONTH:
return [
{
value: TimeFrame.WEEK,
label: t("plans.Weekly"),
},
];
case TimeFrame.WEEK:
return [
{
value: TimeFrame.DAY,
label: t("plans.Daily"),
},
];
default:
// Fallback to yearly if parent timeframe is unknown
return [
{
value: TimeFrame.YEAR,
label: t("plans.Yearly"),
},
];
}
}, [parentId, effectiveParentDetail?.data, watchedParentPlanId, t]);
// Memoize the list options to ensure they update when language changes
const monthListOptions = useMemo(() => getMonthList(t), [t]);
const weekListOptions = useMemo(() => getWeekList(t), [t]);
const dayListOptions = useMemo(() => getDayList(t), [t]);
const quarterListOptions = useMemo(() => getQuarterList(t), [t]);
const watchedTimeframe = useWatch({
control: form.control,
name: "timeframe",
});
// Reset hidden fields when timeframe changes, but preserve context for "drill-down" scenarios
useEffect(() => {
if (watchedTimeframe === TimeFrame.QUARTER) {
form.setValue("month", "0");
form.setValue("week", "0");
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.MONTH) {
form.setValue("week", "0");
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.WEEK) {
form.setValue("day", "0");
} else if (watchedTimeframe === TimeFrame.YEAR) {
form.setValue("quarter", "0");
form.setValue("month", "0");
form.setValue("week", "0");
form.setValue("day", "0");
}
}, [watchedTimeframe, form]);
// Auto-select timeframe if there's only one option
useEffect(() => {
if (timeframeOptions.length === 1) {
const currentVal = form.getValues("timeframe");
if (currentVal !== timeframeOptions[0].value) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}
}, [timeframeOptions, form]);
useEffect(() => {
const current = form.getValues("timeframe");
const isValid = timeframeOptions.some((opt) => opt.value === current);
if (!isValid && timeframeOptions.length > 0) {
form.setValue("timeframe", timeframeOptions[0].value, {
shouldValidate: true,
shouldDirty: true,
});
}
}, [timeframeOptions, form]);
// Inherit timeframe context from parent plan
useEffect(() => {
if (parentId && parentPlanDetail?.data && !isEdit) {
const parent = parentPlanDetail.data;
// Inherit Plan Year
if (parent.planYearId) {
form.setValue("planYearId", parent.planYearId);
}
// Determine Child Timeframe and Context based on Parent
switch (parent.timeframe) {
case TimeFrame.YEAR:
form.setValue("timeframe", TimeFrame.QUARTER);
break;
case TimeFrame.QUARTER:
form.setValue("timeframe", TimeFrame.QUARTER);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
break;
case TimeFrame.MONTH:
form.setValue("timeframe", TimeFrame.WEEK);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
if (parent.month) {
form.setValue("month", parent.month.toString());
}
break;
case TimeFrame.WEEK:
form.setValue("timeframe", TimeFrame.DAY);
if (parent.quarter) {
form.setValue("quarter", parent.quarter.toString());
}
if (parent.month) {
form.setValue("month", parent.month.toString());
}
if (parent.week) {
form.setValue("week", parent.week.toString());
}
break;
default:
break;
}
}
}, [parentId, parentPlanDetail, isEdit, form]);
// Auto-populate plan year when yearId is in URL or when plan year data loads
useEffect(() => {
// If yearId is in URL, use the current plan year data
if (urlYearId && currentPlanYearData?.data) {
form.setValue("planYearId", currentPlanYearData.data.id);
} else if (planYearsData?.data?.items) {
const currentVal = form.getValues("planYearId");
// If current value is a year number (e.g. "2022"), try to find the UUID
const matched = planYearsData.data.items.find(
(y: { id: string; year: number }) =>
y.year.toString() === currentVal || y.id === currentVal,
);
if (matched && matched.id !== currentVal) {
form.setValue("planYearId", matched.id);
}
}
}, [form, planYearsData, urlYearId, currentPlanYearData]);
// Helper function to convert TimeFrame enum to number
// (Removed - not currently used)
const handleSubmit = (values: PlanFormValues) => {
// Check if plan is already accepted (case-insensitive)
const planStatus = planDetail?.data?.status?.toLowerCase();
console.log("Plan status:", planStatus, "Plan detail:", planDetail?.data);
if (isEdit && planStatus === "accepted") {
toast.error(t("plans.planAlreadyAccepted", "Plan is already accepted"), {
description: t(
"plans.cannotEditAcceptedPlan",
"You cannot edit a plan that has already been accepted",
),
});
return;
}
// Create base DTO without timeframe-specific fields
const baseDto = {
id: isEdit ? id : undefined,
name: {
am: values.nameAm,
en: values.nameEn,
},
description: {
am: values.descriptionAm,
en: values.descriptionEn,
},
positionId: positionId || values.positionId || undefined,
parentPlanId:
planId === "root" || !values.parentPlanId
? undefined
: (values.parentPlanId as string),
planType: values.planType || PlanType.PERCENTAGE,
planYearId: values.planYearId,
timeframe: values.timeframe as string,
// initialGoal: Number(values.initialGoal),
// expectedGoal: Number(values.expectedGoal),
weight: Number(values.weight),
planFor: planFor || values.planFor || undefined,
};
// Add timeframe-specific fields based on the selected timeframe
let timeframeSpecificFields = {};
switch (values.timeframe) {
case TimeFrame.YEAR:
// Yearly plans don't need quarter, month, week, day
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: undefined,
};
break;
case TimeFrame.QUARTER:
// Quarterly plans need quarter only
timeframeSpecificFields = {
quarter: Number(values.quarter),
month: undefined,
week: undefined,
day: undefined,
};
break;
case TimeFrame.MONTH:
// Monthly plans need quarter and month
timeframeSpecificFields = {
quarter: undefined,
month: Number(values.month),
week: undefined,
day: undefined,
};
break;
case TimeFrame.WEEK:
// Weekly plans need quarter, month, and week
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: Number(values.week),
day: undefined,
};
break;
case TimeFrame.DAY:
// Daily plans need all timeframe fields
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: Number(values.day),
};
break;
default:
// Fallback - set all to 0
timeframeSpecificFields = {
quarter: undefined,
month: undefined,
week: undefined,
day: undefined,
};
}
// Combine base DTO with timeframe-specific fields
const dto = {
...baseDto,
...timeframeSpecificFields,
};
const mutation = isEdit ? updateMutation : createMutation;
mutation.mutate(dto, {
onSuccess: () => {
toast.success(
isEdit
? t("plans.planUpdatedSuccess", "Plan updated successfully!")
: t("plans.planCreatedSuccess", "Plan created successfully!"),
{
description: `"${values.nameEn}" ${t(
"common.hasBeenText",
"has been",
)} ${
isEdit
? t("common.updated", "updated")
: t("common.created", "created")
}.`,
},
);
onSuccess?.();
if (!isEdit) {
// After creating a plan, navigate to the plans-table view for the
// appropriate year / position / parent plan. Use path params first
// (if the form was opened via a route containing them), otherwise
// fall back to the submitted form values.
const targetYear = urlYearId || values.planYearId || pathYearId;
const targetPosition = positionId || values.positionId;
const targetPlan =
planId || (values.parentPlanId as string) || parentId;
if (targetYear && targetPosition && targetPlan) {
navigate(
`/performance-management/${targetYear}/plans-table/${targetPosition}/${targetPlan}`,
);
} else if (targetYear && targetPosition) {
navigate(
`/performance-management/${targetYear}/plans-table/${targetPosition}`,
);
} else if (targetYear) {
navigate(`/performance-management/${targetYear}/plans`);
} else {
navigate(`/performance-management`);
}
}
},
});
};
// Define form fields with sections
const planFormFields: FormField<PlanFormValues>[] = [
// Basic Information Section
{
name: "nameAm",
label: t("plans.nameAmharic", "Name (Amharic)"),
type: "text",
placeholder: t("plans.planNameAmharic", "የዕቅድ ስም አማርኛ"),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 6,
},
{
name: "nameEn",
label: t("plans.nameEnglish", "Name (English)"),
type: "text",
placeholder: t("plans.planNameEnglish", "Plan Name in English"),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 6,
},
{
name: "descriptionEn",
label: t("plans.descriptionEnglish", "Description (English)"),
type: "textarea",
placeholder: t(
"plans.describePlanEnglish",
"Describe the plan in English...",
),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 12,
},
{
name: "descriptionAm",
label: t("plans.descriptionAmharic", "Description (Amharic)"),
type: "textarea",
placeholder: t("plans.describePlanAmharic", "ዕቅዱን በአማርኛ ይግለጹ..."),
required: true,
icon: FileText,
section: t("plans.basicInformation", "Basic Information"),
colSpan: 12,
},
// Organization Section
{
name: "planYearId",
label: t("plans.planYear", "Plan Year"),
type: "select",
options: planYearOptions,
required: true,
icon: Calendar,
section: t("plans.organization", "Organization"),
colSpan: 4,
disabled: !!urlYearId, // Disable if yearId is in URL params
},
{
name: "timeframe",
label: t("plans.timeFrame", "Time Frame"),
type: "select",
options: timeframeOptions,
placeholder: t("plans.enterTimeFrame", "Enter time frame"),
required: true,
disabled: timeframeOptions.length === 1,
icon: Calendar,
section: t("plans.organization", "Organization"),
colSpan: 4,
},
// {
// name: "initialGoal",
// label: t("plans.initialGoal", "Initial Goal"),
// type: "number",
// required: true,
// icon: Target,
// section: t("plans.organization", "Organization"),
// colSpan: 2,
// },
// {
// name: "expectedGoal",
// label: t("plans.expectedGoal", "Expected Goal"),
// type: "number",
// placeholder: t("plans.enterExpectedGoal", "Enter expected goal"),
// icon: Users,
// section: t("plans.organization", "Organization"),
// colSpan: 2,
// },
// Plan Details Section
{
name: "parentPlanId",
label: t("plans.parentPlan", "Parent Plan"),
type: "select",
options: planOptions,
icon: FileText,
section: t("plans.planDetails", "Plan Details"),
disabled: !!parentId || !!planId,
colSpan: 4,
defaultValue: parentId || planId || undefined,
},
{
name: "weight",
label: t("plans.weight", "Weight"),
type: "number",
placeholder: t("plans.enterWeight", "Enter weight"),
icon: Target,
section: t("plans.planDetails", "Plan Details"),
colSpan: 2,
},
{
name: "month",
label: t("plans.month", "Month"),
type: "select",
placeholder: t("plans.enterMonth", "Enter month"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.MONTH &&
(!form.getValues("month") || form.getValues("month") === "0"),
colSpan: 3,
options: monthListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.MONTH,
},
{
name: "week",
label: t("plans.week", "Week"),
type: "select",
placeholder: t("plans.enterWeek", "Enter week"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.WEEK &&
(!form.getValues("week") || form.getValues("week") === "0"),
colSpan: 4,
options: weekListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.WEEK,
},
{
name: "day",
label: t("plans.day", "Day"),
type: "select",
placeholder: t("plans.enterDay", "Enter day"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden: watchedTimeframe !== TimeFrame.DAY,
colSpan: 4,
options: dayListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.DAY,
},
{
name: "quarter",
label: t("plans.quarter", "Quarter"),
type: "select",
placeholder: t("plans.enterQuarter", "Enter quarter"),
icon: Calendar,
section: t("plans.planDetails", "Plan Details"),
hidden:
watchedTimeframe !== TimeFrame.QUARTER &&
(!form.getValues("quarter") || form.getValues("quarter") === "0"),
colSpan: 4,
options: quarterListOptions,
disabled: parentPlanDetail?.data?.timeframe === TimeFrame.QUARTER,
},
{
name: "planType",
label: t("plans.planType", "Plan Type"),
type: "select",
options: planTypeOptions,
icon: FileText,
section: t("plans.planDetails", "Plan Details"),
colSpan: 4,
},
];
// Filter fields based on timeframe
const filteredFields = planFormFields.filter((field) => {
if (field.hidden) return false;
return true;
});
const handleCancel = () => {
navigate(-1);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 p-4 sm:p-6">
<div className="max-w-4xl mx-auto">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8"
>
<div className="flex items-center space-x-4">
<Button
variant="outline"
size="sm"
onClick={() => navigate(-1)}
className="border-gray-300 text-gray-700 hover:bg-gray-50"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back", "Back")}
</Button>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{isEdit
? t("plans.editPlan", "Edit Plan")
: t("plans.createNewPlan", "Create New Plan")}
</h1>
</div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="bg-white/80 backdrop-blur-sm border-0 shadow-xl overflow-hidden">
<CardContent className="p-6">
{/* Plan Already Accepted Alert */}
{isEdit &&
planDetail?.data?.status?.toLowerCase() === "accepted" && (
<Alert className="mb-6 bg-red-50/80 border-red-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-red-600" />
<AlertTitle className="text-red-900 font-semibold">
{t("plans.planAlreadyAccepted", "Plan Already Accepted")}
</AlertTitle>
<AlertDescription className="text-red-700">
{t(
"plans.cannotEditAcceptedPlan",
"This plan has already been accepted and cannot be edited. Please contact your administrator if you need to make changes.",
)}
</AlertDescription>
</Alert>
)}
{/* Parent Plan Info Alert */}
{parentId && parentPlanDetail?.data && (
<Alert className="mb-6 bg-blue-50/80 border-blue-200/50 backdrop-blur-sm">
<Info className="h-4 w-4 text-blue-600" />
<AlertTitle className="text-blue-900 font-semibold">
{t("plans.creatingSubPlan", "Creating Sub-Plan")}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t(
"plans.subPlanDescription",
"This plan will be created as a sub-plan under:",
)}{" "}
<strong className="text-blue-900">
{localizedName(parentPlanDetail.data.name)}
</strong>
</AlertDescription>
</Alert>
)}
<GenericForm
form={form}
fields={filteredFields.map((field) => ({
...field,
disabled:
field.disabled ||
(isEdit &&
planDetail?.data?.status?.toLowerCase() === "accepted"),
}))}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={
createMutation.isPending || updateMutation.isPending
}
submitButtonText={
isEdit
? t("plans.updatePlan", "Update Plan")
: t("plans.createPlan", "Create Plan")
}
cancelButtonText={t("common.cancel", "Cancel")}
successMessage={
isEdit
? t(
"plans.planUpdatedSuccessfully",
"Plan updated successfully!",
)
: t(
"plans.planCreatedSuccessfully",
"Plan created successfully!",
)
}
errorMessage={
isEdit
? t("plans.failedToUpdatePlan", "Failed to update plan")
: t("plans.failedToCreatePlan", "Failed to create plan")
}
/>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeftIcon,
EditIcon,
CalendarIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Separator } from "@/shared/common/ui/separator";
import { usePlan, checkIsUnitAdmin } from "../../hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useAuth } from "@/shared/context/AuthContext";
import { useMemo, useState } from "react";
import PlanForm from "./PlanForm";
const PlanDetails = () => {
const { id } = useParams();
const navigate = useNavigate();
const localizedName = useLocalizedName();
const { user } = useAuth();
const { yearId } = useParams();
const [searchParams] = useSearchParams();
const positionId = searchParams.get("positionId");
// Check if user is unit admin
const isUnitAdmin = useMemo(() => checkIsUnitAdmin(user), [user]);
// State to control sub-plan form visibility
const [showSubPlanForm, setShowSubPlanForm] = useState(false);
// TanStack Query hook
const { data, isLoading, isError, error, refetch } = usePlan(id || "");
const plan = data?.data;
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-2/5" />
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
</CardContent>
</Card>
<div className="flex gap-4">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-red-600 text-lg mb-4">
Error loading plan details
</div>
<div className="text-muted-foreground mb-4">
{error?.message || "Failed to load plan data. Please try again."}
</div>
<div className="flex gap-4">
<Button onClick={() => refetch()} variant="outline">
Retry
</Button>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/plans")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
if (!plan) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-lg mb-4">Plan not found</div>
<Button
variant="ghost"
onClick={() =>
navigate(
`/performance-management/${yearId}/plans-table/${positionId}`
)
}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Plan Details</h2>
<p className="text-muted-foreground">Plan ID: {plan.id}</p>
</div>
<Button
variant="ghost"
onClick={() =>
navigate(
`/performance-management/${yearId}/plans-table/${positionId}`
)
}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* Plan Name */}
<div>
<div className="text-sm text-muted-foreground mb-2">Plan Name</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base font-semibold">
{localizedName(plan.name)}
</div>
</div>
</div>
{/* Description */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Description
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">{localizedName(plan.description)}</div>
</div>
</div>
{/* Plan Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Calendar Year
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-blue-600" />
<div className="text-base font-semibold">
{plan.calendarYear}
</div>
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Start Date
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{new Date(plan.startDate).toLocaleDateString()}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">End Date</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{new Date(plan.endDate).toLocaleDateString()}
</div>
</div>
</div>
</div>
{/* Related IDs */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Position ID
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div
className="text-base font-mono truncate"
title={plan.positionId}>
{plan.positionId}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">Unit ID</div>
<div className="p-3 border rounded-md bg-muted/50">
<div
className="text-base font-mono truncate"
title={plan.unitId}>
{plan.unitId}
</div>
</div>
</div>
</div>
<Separator />
{/* Timestamps */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Created At
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{plan.createdAt
? new Date(plan.createdAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Last Updated
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{plan.updatedAt
? new Date(plan.updatedAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end pt-4 gap-4">
<Button variant="outline" onClick={() => navigate(-1)}>
Back to Plans
</Button>
{/* Show Edit button only for Unit Admins */}
{isUnitAdmin && (
<Button
onClick={() =>
navigate(`/performance-management/plans/${id}/edit`)
}
className="flex items-center gap-2">
<EditIcon className="h-4 w-4" />
Edit Plan
</Button>
)}
{/* Show Add Sub-Plan button only for Non-Unit Admins */}
{/* {!isUnitAdmin && (
<Button
onClick={() => setShowSubPlanForm(!showSubPlanForm)}
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 to-violet-600 hover:from-purple-600 hover:to-violet-700 text-white">
{showSubPlanForm ? (
<>
<XIcon className="h-4 w-4" />
Cancel
</>
) : (
<>
<PlusIcon className="h-4 w-4" />
Add Sub-Plan
</>
)}
</Button>
)} */}
</div>
</CardContent>
</Card>
{/* Inline Sub-Plan Form */}
{!isUnitAdmin && showSubPlanForm && (
<Card className="border-purple-200 border-2">
<CardContent className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold text-purple-700">
Create Sub-Plan
</h3>
<p className="text-sm text-muted-foreground">
Creating a sub-plan under:{" "}
<span className="font-semibold">
{localizedName(plan.name)}
</span>
</p>
</div>
<Separator className="mb-6" />
<PlanForm
parentPlanId={id}
onSuccess={() => {
setShowSubPlanForm(false);
refetch();
}}
/>
</CardContent>
</Card>
)}
</div>
);
};
export default PlanDetails;

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useAuth } from "@/shared/context/AuthContext";
import { getUserPositionType } from "@/performance-management/utils/positionTypeUtils";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
checkIsPlanInitiator,
useSectorList,
} from "@/performance-management/hooks/usePlans";
import { SectorListView } from "./SectorListView";
import { useAuthUser } from "@/record-management/hooks/useAuthUser";
const PlanHierarchy: React.FC = () => {
const { positionId, yearId } = useParams<{
positionId?: string;
yearId?: string;
}>();
const navigate = useNavigate();
const { user, selectedPositionId } = useAuth();
const { isLoading: isAuthLoading } = useAuthUser();
const isInitiator = useMemo(
() => checkIsPlanInitiator(user, selectedPositionId || undefined),
[user, selectedPositionId]
);
const positionType = getUserPositionType(user);
// Fetch sector list
const { data: sectorData, isLoading, error } = useSectorList(yearId);
console.log("sectorData", sectorData);
// Redirect non-initiators to their position-filtered plans
useEffect(() => {
if (isAuthLoading || !yearId) return;
// If user is not an initiator, redirect them to their own plans
if (!isInitiator) {
// Deputy users go to "my-plans"
if (positionType === EPositionTypeKey.DEPUTY) {
navigate(`/performance-management/${yearId}/my-plans`, {
replace: true,
});
return;
}
// Other users (directors, team leaders, employees) also go to my-plans
navigate(`/performance-management/${yearId}/my-plans`, {
replace: true,
});
return;
}
// If positionId is provided, redirect to the plans table with that position filter
if (positionId) {
navigate(`/performance-management/${yearId}/plans-table/${positionId}`, {
replace: true,
});
}
}, [positionId, yearId, navigate, positionType, isInitiator, isAuthLoading]);
if (isLoading || isAuthLoading) {
return (
<div className="space-y-6 p-6">
<Skeleton className="h-64 w-full rounded-lg" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">
Error Loading Hierarchy
</h3>
<p className="text-red-600 dark:text-red-400">
{error instanceof Error
? error.message
: "Failed to load plan hierarchy"}
</p>
</div>
</div>
);
}
if (!positionType) {
return (
<div className="p-6">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<h3 className="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">
Position Type Not Found
</h3>
<p className="text-yellow-600 dark:text-yellow-400">
Your user account does not have a valid position type assigned.
Please contact your administrator.
</p>
</div>
</div>
);
}
// Show sector list
return (
<div className="p-6">
<SectorListView sectors={sectorData?.data?.items || []} />
</div>
);
};
export default PlanHierarchy;

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/shared/common/ui/button";
import { ArrowLeft } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { getPlanList } from "@/performance-management/services/api/planService";
import { UnifiedHierarchyView } from "./UnifiedHierarchyView";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
interface SectorDetailViewProps {
positionId: string;
positionType: EPositionTypeKey;
}
export const SectorDetailView: React.FC<SectorDetailViewProps> = ({
positionId,
positionType,
}) => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [hierarchyData, setHierarchyData] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const fetchSectorPlans = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Fetch plans for this specific position
const response = await getPlanList(positionId, {
positionId,
take: 100,
skip: 0,
});
setHierarchyData(response?.data);
} catch (err: any) {
console.error("Failed to fetch sector plans:", err);
setError(err.message || "Failed to load sector plans");
} finally {
setLoading(false);
}
}, [positionId]);
useEffect(() => {
fetchSectorPlans();
}, [fetchSectorPlans]);
const handleAddPlan = (targetId?: string) => {
console.log("Add plan clicked for:", targetId || "current level");
// TODO: Implement plan creation modal
};
const handleManageWeekly = () => {
console.log("Manage weekly tasks clicked");
// TODO: Implement weekly tasks management modal
};
const handleViewTeamLead = (teamLeadId: string) => {
console.log("View team lead:", teamLeadId);
// TODO: Implement drill-down navigation
};
const handleViewEmployee = (employeeId: string) => {
console.log("View employee:", employeeId);
// TODO: Implement drill-down navigation
};
const handleBackToSectors = () => {
navigate("/performance-management/plans");
};
if (loading) {
return (
<div className="space-y-6 p-6">
<Skeleton className="h-64 w-full rounded-lg" />
<Skeleton className="h-96 w-full rounded-lg" />
</div>
);
}
if (error) {
return (
<div className="p-6">
<Button
variant="outline"
onClick={handleBackToSectors}
className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sectors
</Button>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">
Error Loading Sector Plans
</h3>
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
);
}
return (
<div className="p-6">
<Button variant="outline" onClick={handleBackToSectors} className="mb-6">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sectors
</Button>
<UnifiedHierarchyView
positionType={positionType}
data={hierarchyData}
onAddPlan={handleAddPlan}
onManageWeekly={handleManageWeekly}
onViewTeamLead={handleViewTeamLead}
onViewEmployee={handleViewEmployee}
/>
</div>
);
};

View File

@@ -0,0 +1,355 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Building2,
Target,
TrendingUp,
Users,
ArrowRight,
Star,
Calendar,
} from "lucide-react";
import { motion } from "framer-motion";
import { SectorList } from "@/performance-management/types/planTypes";
import { useLocalizedName } from "@/shared/common/localizedName";
import { usePlanYears } from "@/performance-management/hooks/usePlanYear";
import { useAuth } from "@/shared/context/AuthContext";
import { getUserPositionType } from "@/performance-management/utils/positionTypeUtils";
interface SectorListViewProps {
sectors: SectorList[];
}
export const SectorListView: React.FC<SectorListViewProps> = ({ sectors }) => {
const navigate = useNavigate();
const { yearId } = useParams<{ yearId: string }>();
const localizedName = useLocalizedName();
const { t } = useTranslation();
// const { data: planYear } = usePlanYears(organizationId);
const handleSectorClick = (positionId: string) => {
if (yearId) {
navigate(`/performance-management/${yearId}/plans-table/${positionId}`);
}
};
const getSectorColor = (index: number) => {
const colors = [
"from-blue-500 to-cyan-500",
"from-purple-500 to-pink-500",
"from-green-500 to-emerald-500",
"from-orange-500 to-red-500",
"from-indigo-500 to-blue-500",
"from-teal-500 to-green-500",
];
return colors[index % colors.length];
};
const getRankBadge = (rank: number) => {
if (rank === 1)
return "bg-gradient-to-r from-yellow-400 to-amber-500 text-white";
if (rank === 2)
return "bg-gradient-to-r from-gray-400 to-gray-500 text-white";
if (rank === 3)
return "bg-gradient-to-r from-amber-600 to-orange-500 text-white";
return "bg-gradient-to-r from-slate-400 to-slate-500 text-white";
};
const getPerformanceColor = (weight: number) => {
if (weight >= 90) return "text-green-500";
if (weight >= 75) return "text-blue-500";
if (weight >= 60) return "text-yellow-500";
return "text-red-500";
};
if (!sectors || sectors.length === 0) {
return (
<div className="min-h-[400px] flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 rounded-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<div className="w-20 h-20 bg-white/80 backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg border border-white/20">
<Building2 className="h-10 w-10 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-700 mb-2">
{t("sectorListView.noSectorsAvailable", "No Sectors Available")}
</h3>
<p className="text-gray-500 max-w-sm">
{t(
"sectorListView.noSectorsMessage",
"There are no sectors to display at this time. Sectors will appear here once they are added to the system."
)}
</p>
</motion.div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4"
>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{t("sectorListView.title", "Sector Overview")}
</h1>
<p className="text-gray-600 mt-2">
{t(
"sectorListView.description",
"Manage and monitor performance across all sectors"
)}
</p>
</div>
<div className="flex items-center gap-3 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-700">
{sectors.length}{" "}
{sectors.length === 1
? t("sectorListView.sector", "Sector")
: t("sectorListView.sectors", "Sectors")}{" "}
{t("sectorListView.active", "Active")}
</span>
</div>
</motion.div>
{/* Stats Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"
>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("sectorListView.totalPlans", "Total Plans")}
</p>
<p className="text-3xl font-bold text-gray-900">
{sectors.reduce(
(sum, sector) => sum + Number(sector.no_of_plans || 0),
0
)}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/10 rounded-2xl flex items-center justify-center">
<Target className="h-6 w-6 text-blue-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("sectorListView.avgWeight", "Avg. Weight")}
</p>
<p className="text-3xl font-bold text-gray-900">
{Math.round(
sectors.reduce(
(sum, sector) => sum + Number(sector.total_weight || 0),
0
) / sectors.length
)}
%
</p>
</div>
<div className="w-12 h-12 bg-green-500/10 rounded-2xl flex items-center justify-center">
<TrendingUp className="h-6 w-6 text-green-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("sectorListView.activeSectors", "Active Sectors")}
</p>
<p className="text-3xl font-bold text-gray-900">
{sectors.filter((s) => Number(s.no_of_plans) > 0).length}
</p>
</div>
<div className="w-12 h-12 bg-purple-500/10 rounded-2xl flex items-center justify-center">
<Users className="h-6 w-6 text-purple-500" />
</div>
</div>
</div>
</motion.div>
{/* Sectors Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{sectors.map((sector, index) => (
<motion.div
key={sector.position_id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleSectorClick(sector.position_id)}
className="group bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl cursor-pointer transform hover:-translate-y-2 transition-all duration-300 border border-gray-200/50 hover:border-purple-300 overflow-hidden"
>
{/* Header with Gradient */}
<div
className={`bg-gradient-to-r ${getSectorColor(
index
)} p-6 relative overflow-hidden`}
>
<div className="absolute top-4 right-4">
<div
className={`px-3 py-1 rounded-full text-xs font-bold ${getRankBadge(
sector.position_rank
)} shadow-lg`}
>
#{sector.position_rank}
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
<Building2 className="h-6 w-6 text-white" />
</div>
<motion.div
whileHover={{ x: 5 }}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
<ArrowRight className="h-5 w-5 text-white/80" />
</motion.div>
</div>
<h3 className="text-xl font-bold text-white line-clamp-2 leading-tight">
{localizedName(sector.position_name)}
</h3>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Plans Count */}
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Target className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("sectorListView.plans", "Plans")}
</p>
<p className="text-lg font-bold text-gray-900">
{sector.no_of_plans || 0}
</p>
</div>
</div>
{sector.no_of_plans > 0 && (
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
)}
</div>
{/* Total Weight */}
{sector.total_weight !== null && (
<div className="flex items-center justify-between p-3 bg-green-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-primary-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("sectorListView.weight", "Weight")}
</p>
<p
className={`text-lg font-bold ${getPerformanceColor(
sector.total_weight
)}`}
>
{sector.total_weight}%
</p>
</div>
</div>
{/* Performance Indicator */}
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < Math.floor(sector.total_weight! / 20)
? "text-yellow-400 fill-current"
: "text-gray-300"
}`}
/>
))}
</div>
</div>
)}
{/* Additional Info */}
<div className="flex items-center justify-between text-sm text-gray-500 pt-2">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{t("sectorListView.active", "Active")}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">
{t("sectorListView.online", "Online")}
</span>
</div>
</div>
</div>
{/* Hover Effect Overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl pointer-events-none"></div>
</motion.div>
))}
</div>
{/* Footer Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-2xl p-6 border border-blue-200/50 mt-8"
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-800">
{t("sectorListView.performanceSummary", "Performance Summary")}
</h3>
<p className="text-gray-600 text-sm">
{t(
"sectorListView.performanceSummaryDescription",
"All sectors are actively monitored and updated in real-time"
)}
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-gray-700">
{t("sectorListView.onTrack", "On Track")}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<span className="text-gray-700">
{t("sectorListView.needsAttention", "Needs Attention")}
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span className="text-gray-700">
{t("sectorListView.atRisk", "At Risk")}
</span>
</div>
</div>
</div>
</motion.div>
</div>
);
};

View File

@@ -0,0 +1,446 @@
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Building2,
Target,
TrendingUp,
Users,
ArrowRight,
Star,
Calendar,
ArrowLeft,
PlusIcon,
} from "lucide-react";
import { motion } from "framer-motion";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useSubPlans } from "@/performance-management/hooks/usePlans";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { SectorList } from "@/performance-management/types/planTypes";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import { useTranslation } from "react-i18next";
interface SubPlanListViewProps {
positionType: EPositionTypeKey;
}
export const SubPlanListView: React.FC<SubPlanListViewProps> = ({
positionType,
}) => {
const navigate = useNavigate();
const { yearId, planId } = useParams<{ yearId: string; planId: string }>();
const localizedName = useLocalizedName();
const { t } = useTranslation();
const POSITION_TYPE_LABELS = {
[EPositionTypeKey.DEPUTY]: {
singular: t("subPlanListView.department"),
plural: t("subPlanListView.departments"),
title: t("subPlanListView.departmentOverview"),
description: t("subPlanListView.departmentDescription"),
},
[EPositionTypeKey.DIRECTOR]: {
singular: t("subPlanListView.director"),
plural: t("subPlanListView.directors"),
title: t("subPlanListView.directorOverview"),
description: t("subPlanListView.directorDescription"),
},
[EPositionTypeKey.TEAM_LEADER]: {
singular: t("subPlanListView.teamLeader"),
plural: t("subPlanListView.teamLeaders"),
title: t("subPlanListView.teamLeaderOverview"),
description: t("subPlanListView.teamLeaderDescription"),
},
[EPositionTypeKey.EMPLOYEE]: {
singular: t("subPlanListView.employee"),
plural: t("subPlanListView.employees"),
title: t("subPlanListView.employeeOverview"),
description: t("subPlanListView.employeeDescription"),
},
} as const;
const { data, isLoading, error } = useSubPlans(planId || "");
const items: SectorList[] = data?.data?.items || [];
const labels =
POSITION_TYPE_LABELS[positionType as keyof typeof POSITION_TYPE_LABELS] ||
POSITION_TYPE_LABELS[EPositionTypeKey.DEPUTY];
const handleItemClick = (positionId: string, planId: string) => {
if (yearId) {
// Navigate to plans table for this sub-plan
navigate(
`/performance-management/${yearId}/plans-table/${positionId}/${planId}`
);
}
};
const handleCreateSubPlan = (item: SectorList) => {
if (yearId && planId) {
// Navigate to create plan with both positionId and parentPlanId
navigate(
`/performance-management/${yearId}/plans/new/${item.position_id}?parentPlanId=${planId}`
);
}
};
const getSectorColor = (index: number) => {
const colors = [
"from-blue-500 to-cyan-500",
"from-purple-500 to-pink-500",
"from-green-500 to-emerald-500",
"from-orange-500 to-red-500",
"from-indigo-500 to-blue-500",
"from-teal-500 to-green-500",
];
return colors[index % colors.length];
};
const getRankBadge = (rank: number) => {
if (rank === 1)
return "bg-gradient-to-r from-yellow-400 to-amber-500 text-white";
if (rank === 2)
return "bg-gradient-to-r from-gray-400 to-gray-500 text-white";
if (rank === 3)
return "bg-gradient-to-r from-amber-600 to-orange-500 text-white";
return "bg-gradient-to-r from-slate-400 to-slate-500 text-white";
};
const getPerformanceColor = (weight: number) => {
if (weight >= 90) return "text-green-500";
if (weight >= 75) return "text-blue-500";
if (weight >= 60) return "text-yellow-500";
return "text-red-500";
};
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-64 w-full rounded-lg" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-red-800 dark:text-red-200 font-semibold mb-2">
{t("subPlanListView.errorLoading")} {labels.plural}
</h3>
<p className="text-red-600 dark:text-red-400">
{error instanceof Error
? error.message
: `${t(
"subPlanListView.failedToLoad"
)} ${labels.plural.toLowerCase()}`}
</p>
<Button
variant="outline"
onClick={() => navigate(-1)}
className="mt-4 border-red-200 text-red-600 hover:bg-red-100"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("subPlanListView.goBack")}
</Button>
</div>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="p-6">
<Button variant="outline" onClick={() => navigate(-1)} className="mb-6">
<ArrowLeft className="mr-2 h-4 w-4" />
{t("subPlanListView.back")}
</Button>
<div className="min-h-[400px] flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 rounded-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center"
>
<div className="w-20 h-20 bg-white/80 backdrop-blur-sm rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg border border-white/20">
<Building2 className="h-10 w-10 text-gray-400" />
</div>
<h3 className="text-xl font-semibold text-gray-700 mb-2">
{t("subPlanListView.noAvailable", { type: labels.plural })}
</h3>
<p className="text-gray-500 max-w-sm">
{t("subPlanListView.noSubPlansToDisplay", {
type: labels.plural.toLowerCase(),
})}
</p>
</motion.div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4"
>
<div>
<div className="flex items-center gap-2 mb-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
className="text-gray-500 hover:text-gray-700 -ml-2"
>
<ArrowLeft className="h-4 w-4 mr-1" />
{t("subPlanListView.back")}
</Button>
</div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-800 to-gray-600 bg-clip-text text-transparent">
{labels.title}
</h1>
<p className="text-gray-600 mt-2">{labels.description}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 bg-white/80 backdrop-blur-sm rounded-2xl px-4 py-2 shadow-sm border border-gray-200">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-sm font-medium text-gray-700">
{items.length}{" "}
{items.length === 1 ? labels.singular : labels.plural}{" "}
{t("subPlanListView.active")}
</span>
</div>
</div>
</motion.div>
{/* Stats Summary */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"
>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("subPlanListView.totalPlans")}
</p>
<p className="text-3xl font-bold text-gray-900">
{items.reduce(
(sum: number, item: SectorList) =>
sum + (Number(item.no_of_plans) || 0),
0
)}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/10 rounded-2xl flex items-center justify-center">
<Target className="h-6 w-6 text-blue-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("subPlanListView.avgWeight")}
</p>
<p className="text-3xl font-bold text-gray-900">
{items.length > 0
? Math.round(
items.reduce(
(sum: number, item: SectorList) =>
sum + (Number(item.total_weight) || 0),
0
) / items.length
)
: 0}
%
</p>
</div>
<div className="w-12 h-12 bg-green-500/10 rounded-2xl flex items-center justify-center">
<TrendingUp className="h-6 w-6 text-green-500" />
</div>
</div>
</div>
<div className="bg-white/80 backdrop-blur-sm rounded-2xl p-6 shadow-lg border border-gray-200/50">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm font-medium">
{t("subPlanListView.active")} {labels.plural}
</p>
<p className="text-3xl font-bold text-gray-900">
{
items.filter((s: SectorList) => Number(s.no_of_plans) > 0)
.length
}
</p>
</div>
<div className="w-12 h-12 bg-purple-500/10 rounded-2xl flex items-center justify-center">
<Users className="h-6 w-6 text-purple-500" />
</div>
</div>
</div>
</motion.div>
{/* Items Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{items.map((item: SectorList, index: number) => (
<motion.div
key={item.position_id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
className="group bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg hover:shadow-2xl transform hover:-translate-y-2 transition-all duration-300 border border-gray-200/50 hover:border-purple-300 overflow-hidden"
>
{/* Header with Gradient */}
<div
className={`bg-gradient-to-r ${getSectorColor(
index
)} p-6 relative overflow-hidden`}
>
<div className="absolute top-4 right-4">
<div
className={`px-3 py-1 rounded-full text-xs font-bold ${getRankBadge(
item.position_rank
)} shadow-lg`}
>
#{item.position_rank}
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center backdrop-blur-sm">
<Building2 className="h-6 w-6 text-white" />
</div>
<motion.div
whileHover={{ x: 5 }}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-300"
>
<ArrowRight className="h-5 w-5 text-white/80" />
</motion.div>
</div>
<h3 className="text-xl font-bold text-white line-clamp-2 leading-tight">
{localizedName(item.position_name)}
</h3>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Plans Count */}
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Target className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("subPlanListView.totalPlans")}
</p>
<p className="text-lg font-bold text-gray-900">
{item.no_of_plans || 0}
</p>
</div>
</div>
{Number(item.no_of_plans) > 0 && (
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
)}
</div>
{/* Total Weight */}
{item.total_weight !== null && (
<div className="flex items-center justify-between p-3 bg-green-50 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-500/10 rounded-lg flex items-center justify-center">
<TrendingUp className="h-5 w-5 text-primary-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-600">
{t("subPlanListView.avgWeight")}
</p>
<p
className={`text-lg font-bold ${getPerformanceColor(
item.total_weight
)}`}
>
{item.total_weight}%
</p>
</div>
</div>
{/* Performance Indicator */}
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`h-4 w-4 ${
i < Math.floor((item.total_weight || 0) / 20)
? "text-yellow-400 fill-current"
: "text-gray-300"
}`}
/>
))}
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-4">
<Button
onClick={(e) => {
e.stopPropagation();
handleItemClick(item.position_id, planId as string);
}}
variant="outline"
size="sm"
className="flex-1 border-blue-200 text-blue-600 hover:bg-blue-50"
>
{t("subPlanListView.viewPlans")}
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleCreateSubPlan(item);
}}
size="sm"
className="flex-1 bg-gradient-to-r from-purple-500 to-violet-600 hover:from-purple-600 hover:to-violet-700 text-white"
>
<PlusIcon className="h-4 w-4 mr-1" />
{t("subPlanListView.createPlan")}
</Button>
</div>
{/* Additional Info */}
<div className="flex items-center justify-between text-sm text-gray-500 pt-2">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{t("subPlanListView.active")}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="font-medium">
{t("subPlanListView.online")}
</span>
</div>
</div>
</div>
{/* Hover Effect Overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/5 to-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-2xl pointer-events-none"></div>
</motion.div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,373 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import {
Plus,
ChevronDown,
ChevronRight,
User,
Calendar,
CheckCircle2,
} from "lucide-react";
import { PlansTable } from "./shared/PlansTable";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import {
WeeklyTask,
Employee,
TeamLead,
Directorate,
} from "@/performance-management/types/planHierarchyTypes";
// Props for the unified view
interface UnifiedHierarchyViewProps {
positionType: EPositionTypeKey;
data: any; // Will be typed based on API response
onAddPlan: (targetId?: string) => void;
onManageWeekly?: () => void;
onViewTeamLead?: (teamLeadId: string) => void;
onViewEmployee?: (employeeId: string) => void;
}
export const UnifiedHierarchyView: React.FC<UnifiedHierarchyViewProps> = ({
positionType,
data,
onAddPlan,
onManageWeekly,
onViewTeamLead,
onViewEmployee,
}) => {
const [expandedDirectorate, setExpandedDirectorate] = useState<string | null>(
null
);
const toggleDirectorate = (id: string) => {
setExpandedDirectorate((prev) => (prev === id ? null : id));
};
// Deputy (Sector) View
if (positionType === EPositionTypeKey.DEPUTY) {
return (
<div className="space-y-6">
{/* Sector Plans Overview */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Sector Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Sector Plan
</Button>
</div>
<PlansTable plans={data?.plans || []} editable />
</div>
{/* Directorates Section */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Directorates
</h2>
<div className="space-y-2">
{(data?.directorates || []).map((dir: Directorate) => (
<div
key={dir.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg">
{/* Directorate Header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => toggleDirectorate(dir.id)}>
<div className="flex items-center gap-3">
{expandedDirectorate === dir.id ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<div>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{dir.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{dir.planCount} plans {dir.progress}% complete
</p>
</div>
</div>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddPlan(dir.id);
}}>
<Plus className="h-4 w-4 mr-1" />
Add Plan
</Button>
</div>
{/* Expanded Directorate Plans */}
{expandedDirectorate === dir.id && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
{dir.plans?.length > 0 ? (
<PlansTable plans={dir.plans} />
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No plans added yet
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}
// Director (Directorate) View
if (positionType === EPositionTypeKey.DIRECTOR) {
return (
<div className="space-y-6">
{/* Directorate Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={data?.plans || []} editable />
</div>
{/* Team Leads List */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Leads
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(data?.teamLeads || []).map((tl: TeamLead) => (
<div
key={tl.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewTeamLead?.(tl.id)}>
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
{tl.name}
</h3>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>{tl.planCount} plans assigned</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${tl.progress}%` }}
/>
</div>
<span>{tl.progress}%</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
// Team Leader View
if (positionType === EPositionTypeKey.TEAM_LEADER) {
return (
<div className="space-y-6">
{/* Team Lead Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={data?.plans || []} />
</div>
{/* Weekly Milestones */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Weekly Milestones
</h2>
<Button onClick={onManageWeekly} variant="outline">
Manage Weekly Tasks
</Button>
</div>
<div className="space-y-2">
{(data?.weeklyMilestones || []).length > 0 ? (
data.weeklyMilestones.map((milestone: string, index: number) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded">
<div className="h-2 w-2 rounded-full bg-purple-600"></div>
<span className="text-sm text-gray-700 dark:text-gray-300">
{milestone}
</span>
</div>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly milestones set
</div>
)}
</div>
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Members
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(data?.employees || []).map((employee: Employee) => (
<div
key={employee.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewEmployee?.(employee.id)}>
<div className="flex items-start gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded">
<User className="h-5 w-5 text-purple-600 dark:text-purple-300" />
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{employee.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{employee.position}
</p>
<div className="mt-2 space-y-1 text-xs text-gray-500 dark:text-gray-400">
<p>
{employee.planCount} plans {employee.weeklyTasksCount}{" "}
weekly tasks
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
// Employee View
if (positionType === EPositionTypeKey.EMPLOYEE) {
return (
<div className="space-y-6">
{/* Weekly Expectations */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
My Weekly Expectations
</h2>
{(data?.weeklyTasks || []).length > 0 ? (
<div className="space-y-3">
{data.weeklyTasks.map((task: WeeklyTask) => (
<div
key={task.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{task.description}
</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
task.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-primary-300"
: task.status === "in-progress"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}>
{task.status}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Expected Outcome:</strong> {task.expectedOutcome}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Deadline:</strong> {task.deadline}
</p>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly tasks assigned yet
</div>
)}
</div>
{/* Assigned Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Assigned Plans
</h2>
<PlansTable plans={data?.plans || []} />
</div>
{/* Performance Summary */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Performance Summary
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="text-sm text-purple-600 dark:text-purple-400 mb-1">
Total Plans
</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-100">
{data?.plans?.length || 0}
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="text-sm text-blue-600 dark:text-blue-400 mb-1">
Weekly Tasks
</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-100">
{data?.weeklyTasks?.length || 0}
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-primary-900/20 rounded-lg">
<div className="text-sm text-primary-600 dark:text-primary-400 mb-1">
Completed
</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
{data?.weeklyTasks?.filter(
(t: WeeklyTask) => t.status === "completed"
).length || 0}
</div>
</div>
</div>
</div>
</div>
);
}
// Fallback for unsupported position types
return (
<div className="p-6">
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="text-gray-800 dark:text-gray-200 font-semibold mb-2">
Unsupported Position Type
</h3>
<p className="text-gray-600 dark:text-gray-400">
Your position type is not supported in the plan hierarchy view.
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import React from "react";
import { Button } from "@/shared/common/ui/button";
import { Edit, Trash2 } from "lucide-react";
import { Plan } from "@/performance-management/types/planHierarchyTypes";
interface PlansTableProps {
plans: Plan[];
editable?: boolean;
onEdit?: (planId: string) => void;
onDelete?: (planId: string) => void;
}
const StatusBadge: React.FC<{ status: Plan["status"] }> = ({ status }) => {
const statusColors = {
pending:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
"in-progress":
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
completed:
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-primary-300",
overdue: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[status]}`}>
{status}
</span>
);
};
const ProgressBar: React.FC<{ progress: number }> = ({ progress }) => {
return (
<div className="w-full">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-sm text-gray-600 dark:text-gray-400 min-w-[3rem]">
{progress}%
</span>
</div>
</div>
);
};
export const PlansTable: React.FC<PlansTableProps> = ({
plans,
editable = false,
onEdit,
onDelete,
}) => {
if (!plans || plans.length === 0) {
return (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No plans available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-100 dark:bg-gray-800">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Plan Name
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Weight
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Timeline
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Status
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Progress
</th>
{editable && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-200">
Actions
</th>
)}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{plans.map((plan) => (
<tr
key={plan.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{plan.name}
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{plan.weight}
</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{plan.timeline}
</td>
<td className="px-4 py-3">
<StatusBadge status={plan.status} />
</td>
<td className="px-4 py-3">
<ProgressBar progress={plan.progress} />
</td>
{editable && (
<td className="px-4 py-3">
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => onEdit?.(plan.id)}>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDelete?.(plan.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -0,0 +1,100 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { DeputyData } from "@/performance-management/types/planHierarchyTypes";
interface DeputyViewProps {
sectorData: DeputyData;
onAddPlan: (targetId?: string) => void;
}
export const DeputyView: React.FC<DeputyViewProps> = ({
sectorData,
onAddPlan,
}) => {
const [expandedDirectorate, setExpandedDirectorate] = useState<string | null>(
null
);
const toggleDirectorate = (id: string) => {
setExpandedDirectorate((prev) => (prev === id ? null : id));
};
return (
<div className="space-y-6">
{/* Sector Plans Overview */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Sector Plans
</h2>
<Button onClick={() => onAddPlan()}>
<Plus className="mr-2 h-4 w-4" />
Add Sector Plan
</Button>
</div>
<PlansTable plans={sectorData.plans} editable />
</div>
{/* Directorates Section */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Directorates
</h2>
{/* Directorates List */}
<div className="space-y-2">
{sectorData.directorates.map((dir) => (
<div
key={dir.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg">
{/* Directorate Header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => toggleDirectorate(dir.id)}>
<div className="flex items-center gap-3">
{expandedDirectorate === dir.id ? (
<ChevronDown className="h-5 w-5 text-gray-500" />
) : (
<ChevronRight className="h-5 w-5 text-gray-500" />
)}
<div>
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{dir.name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{dir.planCount} plans {dir.progress}% complete
</p>
</div>
</div>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddPlan(dir.id);
}}>
<Plus className="h-4 w-4 mr-1" />
Add Plan
</Button>
</div>
{/* Expanded Directorate Plans */}
{expandedDirectorate === dir.id && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
{dir.plans.length > 0 ? (
<PlansTable plans={dir.plans} />
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No plans added yet
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button } from "@/shared/common/ui/button";
import { Plus } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { DirectorData } from "@/performance-management/types/planHierarchyTypes";
interface DirectorViewProps {
directorateData: DirectorData;
onAddPlan: () => void;
onViewTeamLead: (teamLeadId: string) => void;
}
export const DirectorView: React.FC<DirectorViewProps> = ({
directorateData,
onAddPlan,
onViewTeamLead,
}) => {
return (
<div className="space-y-6">
{/* Directorate Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={onAddPlan}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={directorateData.plans} editable />
</div>
{/* Team Leads List */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Leads
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{directorateData.teamLeads.map((tl) => (
<div
key={tl.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewTeamLead(tl.id)}>
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
{tl.name}
</h3>
<div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p>{tl.planCount} plans assigned</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all"
style={{ width: `${tl.progress}%` }}
/>
</div>
<span>{tl.progress}%</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,103 @@
import React from "react";
import { Calendar, CheckCircle2 } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { EmployeeData } from "@/performance-management/types/planHierarchyTypes";
interface EmployeeViewProps {
employeeData: EmployeeData;
}
export const EmployeeView: React.FC<EmployeeViewProps> = ({ employeeData }) => {
return (
<div className="space-y-6">
{/* Weekly Expectations */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<Calendar className="h-5 w-5" />
My Weekly Expectations
</h2>
{employeeData.weeklyTasks.length > 0 ? (
<div className="space-y-3">
{employeeData.weeklyTasks.map((task) => (
<div
key={task.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{task.description}
</h3>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
task.status === "completed"
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-primary-300"
: task.status === "in-progress"
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"
: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
}`}>
{task.status}
</span>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
<strong>Expected Outcome:</strong> {task.expectedOutcome}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
<strong>Deadline:</strong> {task.deadline}
</p>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly tasks assigned yet
</div>
)}
</div>
{/* Assigned Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
Assigned Plans
</h2>
<PlansTable plans={employeeData.plans} />
</div>
{/* Performance Summary */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Performance Summary
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="text-sm text-purple-600 dark:text-purple-400 mb-1">
Total Plans
</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-100">
{employeeData.plans.length}
</div>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="text-sm text-blue-600 dark:text-blue-400 mb-1">
Weekly Tasks
</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-100">
{employeeData.weeklyTasks.length}
</div>
</div>
<div className="p-4 bg-green-50 dark:bg-primary-900/20 rounded-lg">
<div className="text-sm text-primary-600 dark:text-primary-400 mb-1">
Completed
</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-100">
{
employeeData.weeklyTasks.filter((t) => t.status === "completed")
.length
}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,103 @@
import React from "react";
import { Button } from "@/shared/common/ui/button";
import { Plus, User } from "lucide-react";
import { PlansTable } from "../shared/PlansTable";
import { TeamLeaderData } from "@/performance-management/types/planHierarchyTypes";
interface TeamLeaderViewProps {
teamLeaderData: TeamLeaderData;
onAddPlan: () => void;
onManageWeekly: () => void;
onViewEmployee: (employeeId: string) => void;
}
export const TeamLeaderView: React.FC<TeamLeaderViewProps> = ({
teamLeaderData,
onAddPlan,
onManageWeekly,
onViewEmployee,
}) => {
return (
<div className="space-y-6">
{/* Team Lead Plans */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
My Plans
</h2>
<Button onClick={onAddPlan}>
<Plus className="mr-2 h-4 w-4" />
Add Plan
</Button>
</div>
<PlansTable plans={teamLeaderData.plans} />
</div>
{/* Weekly Milestones */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
Weekly Milestones
</h2>
<Button onClick={onManageWeekly} variant="outline">
Manage Weekly Tasks
</Button>
</div>
<div className="space-y-2">
{teamLeaderData.weeklyMilestones.length > 0 ? (
teamLeaderData.weeklyMilestones.map((milestone, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded">
<div className="h-2 w-2 rounded-full bg-purple-600"></div>
<span className="text-sm text-gray-700 dark:text-gray-300">
{milestone}
</span>
</div>
))
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No weekly milestones set
</div>
)}
</div>
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Team Members
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{teamLeaderData.employees.map((employee) => (
<div
key={employee.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition cursor-pointer"
onClick={() => onViewEmployee(employee.id)}>
<div className="flex items-start gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded">
<User className="h-5 w-5 text-purple-600 dark:text-purple-300" />
</div>
<div className="flex-1">
<h3 className="font-medium text-gray-900 dark:text-gray-100">
{employee.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{employee.position}
</p>
<div className="mt-2 space-y-1 text-xs text-gray-500 dark:text-gray-400">
<p>
{employee.planCount} plans {employee.weeklyTasksCount}{" "}
weekly tasks
</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import { Calendar, Edit, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PlanYear } from "../../types/planYearTypes";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useNavigate, useLocation } from "react-router-dom";
interface PlanYearCardProps {
planYear: PlanYear;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
isUnitAdmin?: boolean;
}
export const PlanYearCard = ({
planYear,
onEdit,
onDelete,
isUnitAdmin = false,
}: PlanYearCardProps) => {
const localizedName = useLocalizedName();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
console.log("plan yera ", planYear.id);
const handleCardClick = () => {
// Check if we're in the OKR context (objective-management)
const isOKRContext = location.pathname.startsWith("/objective-management");
const planYearLabel = localizedName(planYear.name) || String(planYear.year);
const routeState = {
planYearName: planYearLabel,
planYearLabel,
planYear: {
id: planYear.id,
year: planYear.year,
name: planYear.name,
},
};
if (isOKRContext) {
navigate(`/objective-management/${planYear.id}/dashboard`, {
state: routeState,
});
} else {
navigate(`/performance-management/${planYear.id}/dashboard`, {
state: routeState,
});
}
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
onEdit?.(planYear.id);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete?.(planYear.id);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
return (
<div
onClick={handleCardClick}
className="group relative bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-lg hover:border-purple-300 dark:hover:border-purple-600 transition-all cursor-pointer"
>
{/* Active Badge */}
{planYear.isActive && (
<div className="absolute top-3 right-3">
<Badge className="bg-green-500 hover:bg-primary-600 text-white">
{t("planYearList.active", "Active")}
</Badge>
</div>
)}
{/* Year Number - Large Display */}
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Calendar className="h-6 w-6 text-purple-600 dark:text-purple-300" />
</div>
<h3 className="text-3xl font-bold text-gray-900 dark:text-white">
{planYear.year}
</h3>
</div>
{/* Name */}
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">
{localizedName(planYear.name)}
</h4>
{/* Description */}
{planYear.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
{localizedName(planYear.description)}
</p>
)}
{/* Actions - Only visible to unit admins */}
{isUnitAdmin && (
<div className="flex gap-2 pt-4 border-t border-gray-200 dark:border-gray-700 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="outline"
size="sm"
onClick={handleEdit}
className="flex-1"
>
<Edit className="h-4 w-4 mr-1" />
{t("planYearList.edit", "Edit")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4 mr-1" />
{t("planYearList.delete", "Delete")}
</Button>
</div>
)}
{/* Click indicator */}
<div className="absolute bottom-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-purple-600 dark:text-purple-400 font-medium">
{t("planYearList.viewPlans", "View Plans")}
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,151 @@
import { useForm } from "react-hook-form";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { FormField } from "@/performance-management/utils/shared-field-types";
import {
usePlanYear,
usePlanYearMutations,
usePlanYears,
} from "@/performance-management/hooks/usePlanYear";
import { PlanYearFormValues } from "@/performance-management/types/planYearTypes";
import { useAuth } from "@/shared/context/AuthContext";
import { generateEthiopianYearOptions } from "@/performance-management/utils/EthiopianYear";
import { useMemo } from "react";
export default function PlanYearForm() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const isEdit = !!id;
const { data: planYearData } = usePlanYear(id || "");
const { create, update } = usePlanYearMutations();
const { user } = useAuth();
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
// Fetch all plan years to check which years are taken
const { data: allPlanYears } = usePlanYears(organizationId || "");
const yearOptions = useMemo(() => {
const baseOptions = generateEthiopianYearOptions();
// Get taken years (excluding current plan year if editing)
const takenYears = new Set(
allPlanYears?.data?.items
?.filter((planYear: { id: string | undefined }) => planYear.id !== id)
.map((planYear: { year: any }) => planYear.year) || []
);
// Mark taken years as disabled
return baseOptions.map((option) => ({
...option,
disabled: takenYears.has(Number(option.value)),
}));
}, [allPlanYears, id]);
const initialValues: PlanYearFormValues = {
year: Number(yearOptions[0].value),
nameAm: planYearData?.data?.name?.am || "",
nameEn: planYearData?.data?.name?.en || "",
descriptionAm: "",
descriptionEn: "",
organizationId: organizationId || "",
};
const form = useForm<PlanYearFormValues>({
defaultValues: initialValues,
values: initialValues,
});
const planYearFormFields: FormField<PlanYearFormValues>[] = [
{
name: "year",
label: t("planYear.year", "Year"),
type: "year-picker",
placeholder: t("planYear.enterYear", "Enter year (e.g., 2025)"),
required: true,
defaultValue: Number(yearOptions[0].value),
calendarType: "habesha",
language: "am",
minYear: !isEdit ? Number(yearOptions[0].value) : undefined,
colSpan: 12,
},
{
name: "nameAm",
label: t("planYear.nameAmharic", "Name (Amharic)"),
type: "text",
placeholder: t("planYear.enterAmharicName", "Enter Amharic name"),
required: true,
colSpan: 6,
},
{
name: "nameEn",
label: t("planYear.nameEnglish", "Name (English)"),
type: "text",
placeholder: t("planYear.enterEnglishName", "Enter English name"),
required: true,
colSpan: 6,
},
];
const handleSubmit = (values: PlanYearFormValues) => {
const dto = {
id: isEdit ? id : undefined,
year: Number(values.year),
name: {
am: values.nameAm,
en: values.nameEn,
},
organizationId: organizationId,
};
const mutation = isEdit ? update : create;
mutation.mutate(dto, {
onSuccess: () => {
navigate("/performance-management/plan-years");
},
});
};
const handleCancel = () => {
navigate("/performance-management/plan-years");
};
return (
<div className="container mx-auto px-4 py-8">
<GenericForm<PlanYearFormValues>
form={form}
fields={planYearFormFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
title={
isEdit
? t("planYear.editPlanYear", "Edit Plan Year")
: t("planYear.createPlanYear", "Create Plan Year")
}
description={
isEdit
? t(
"planYear.updatePlanYearInfo",
"Update the plan year information"
)
: t(
"planYear.createPlanYearInfo",
"Create a new plan year to organize your plans"
)
}
submitButtonText={
isEdit
? t("planYear.updatePlanYear", "Update Plan Year")
: t("planYear.createPlanYear", "Create Plan Year")
}
cancelButtonText={t("common.cancel", "Cancel")}
/>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import { useState, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { PlusIcon, RefreshCw } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { PlanYearCard } from "./PlanYearCard";
import {
usePlanYears,
usePlanYearMutations,
} from "@/performance-management/hooks/usePlanYear";
import { PlanYear } from "@/performance-management/types/planYearTypes";
import { useAuth } from "@/shared/context/AuthContext";
import {
checkIsPlanInitiator,
checkIsUnitAdmin,
} from "@/performance-management/hooks/usePlans";
import { useLocalizedName } from "@/shared/common/localizedName";
export default function PlanYearList() {
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { user } = useAuth();
const localizedName = useLocalizedName();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedPlanYear, setSelectedPlanYear] = useState<PlanYear | null>(
null
);
const basePath = useMemo(() => {
const segments = location.pathname.startsWith("/objective");
if (segments) {
return "/objective-management";
}
return "/performance-management";
}, [location.pathname]);
const organizationId: string =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: "";
// Check if user is unit admin
const isUnitAdmin = useMemo(() => checkIsPlanInitiator(user), [user]);
console.log("isUnitAdmin", isUnitAdmin);
const { data, isLoading, isFetching, isError, error, refetch } = usePlanYears(
organizationId,
{
take: 100,
skip: 0,
}
);
const { delete: deletePlanYear } = usePlanYearMutations();
const planYears = data?.data?.items || [];
const handleEdit = (id: string) => {
navigate(`${basePath}/plan-years/${id}/edit`);
};
const handleRefresh = () => {
queryClient.invalidateQueries({
queryKey: ["planYears", "list", organizationId],
});
};
const handleDelete = (id: string) => {
const planYear = planYears.find((py: PlanYear) => py.id === id);
if (planYear) {
setSelectedPlanYear(planYear);
setIsDeleteDialogOpen(true);
}
};
const confirmDelete = () => {
if (!selectedPlanYear) return;
deletePlanYear.mutate(selectedPlanYear.id, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setSelectedPlanYear(null);
},
});
};
if (isLoading) {
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-10 w-40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-64 w-full rounded-lg" />
))}
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 space-y-4">
<div className="text-destructive text-lg font-semibold">
{t("planYear.loadingError", "Error loading plan years")}
</div>
<div className="text-muted-foreground">
{error?.message ||
t(
"planYear.loadingErrorMessage",
"Failed to load plan years. Please try again."
)}
</div>
<Button onClick={() => refetch()}>
{t("planYear.retry", "Retry")}
</Button>
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{t("planYearList.title", "Plan Years")}
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t("planYearList.description", "Manage and organize plans by year")}
</p>
</div>
<div className="flex flex-wrap gap-2">
{isUnitAdmin && (
<Button
onClick={() => navigate(`${basePath}/plan-years/new`)}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white">
<PlusIcon className="h-4 w-4 mr-2" />
{t("planYearList.createPlanYear", "Create Plan Year")}
</Button>
)}
<Button
variant="outline"
onClick={handleRefresh}
disabled={isFetching}
className="gap-2"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
{t("common.refresh", "Refresh")}
</Button>
</div>
</div>
{/* Plan Years Grid */}
{planYears.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 dark:text-gray-500 mb-4">
<svg
className="mx-auto h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{t("planYearList.noPlanYears", "No plan years yet")}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{t(
"planYearList.getStarted",
"Get started by creating your first plan year"
)}
</p>
{isUnitAdmin && (
<Button
onClick={() => navigate("/performance-management/plan-years/new")}
className="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white">
<PlusIcon className="h-4 w-4 mr-2" />
{t("planYearList.createPlanYear", "Create Plan Year")}
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{planYears.map((planYear: PlanYear) => (
<PlanYearCard
key={planYear.id}
planYear={planYear}
onEdit={handleEdit}
onDelete={handleDelete}
isUnitAdmin={isUnitAdmin}
/>
))}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("planYearList.confirmDeletion", "Confirm Deletion")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"planYearList.deleteConfirmation",
'Are you sure you want to delete the plan year "{{year}} - {{name}}"? This action cannot be undone and may affect associated plans.',
{
year: selectedPlanYear?.year,
name: localizedName(selectedPlanYear?.name),
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedPlanYear(null);
}}
disabled={deletePlanYear.isPending}>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deletePlanYear.isPending}
className="bg-red-600 hover:bg-red-700">
{deletePlanYear.isPending ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent mr-2" />
{t("planYearList.deleting", "Deleting...")}
</>
) : (
t("planYearList.delete", "Delete")
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeftIcon, EditIcon, ClockIcon } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { Card, CardContent } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Separator } from "@/shared/common/ui/separator";
import { useService } from "@/performance-management/hooks/useServiceHook"; // your hook
import { useLocalizedName } from "@/shared/common/localizedName";
const ServiceDetails = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const localizedName = useLocalizedName();
const { data: service, isLoading, isError, refetch } = useService(id || "");
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-2/5" />
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
<Skeleton className="h-5 w-1/4" />
<Skeleton className="h-9 w-3/5" />
</CardContent>
</Card>
<div className="flex gap-4">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
);
}
if (isError || !service) {
return (
<div className="p-6 space-y-6">
<Card>
<CardContent className="p-6">
<div className="text-red-600 text-lg mb-4">
{isError ? "Error loading service details" : "Service not found"}
</div>
<div className="flex gap-4">
<Button onClick={() => refetch()} variant="outline">
Retry
</Button>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/services")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Service Details</h2>
<Button
variant="ghost"
onClick={() => navigate("/performance-management/services")}
className="flex items-center gap-2">
<ArrowLeftIcon className="h-4 w-4" />
Back to List
</Button>
</div>
<Card>
<CardContent className="p-6 space-y-6">
{/* Service Name */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Service Name
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{localizedName(service.data.name)}
</div>
</div>
</div>
{/* Description */}
<div>
<div className="text-sm text-muted-foreground mb-2">
Description
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{localizedName(service.data.description)}
</div>
</div>
</div>
{/* Category */}
<div>
<div className="text-sm text-muted-foreground mb-2">Created At</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{service.data.amharicCreatedAt || "N/A"}
</div>
</div>
</div>
{/* Estimated Duration and Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Estimated Duration
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="flex items-center gap-2">
<ClockIcon className="h-4 w-4" />
<div className="text-base">
{service.data.estimatedTime}{" "}
{service.data.estimatedTimeUnit?.toLowerCase()}
</div>
</div>
</div>
</div>
</div>
<Separator />
{/* Timestamps */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-muted-foreground mb-2">
Created At
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{service.data.createdAt
? new Date(service.data.createdAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-2">
Last Updated
</div>
<div className="p-3 border rounded-md bg-muted/50">
<div className="text-base">
{service.data.updatedAt
? new Date(service.data.updatedAt).toLocaleString()
: "N/A"}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default ServiceDetails;

View File

@@ -0,0 +1,338 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import DateMeasurementUnit from "@/performance-management/utils/data-measurement-unit";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { FormField } from "@/performance-management/utils/shared-field-types";
import {
useService,
useServiceMutations,
useServiceList,
} from "@/performance-management/hooks/useServiceHook";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useServiceCategoryList } from "@/performance-management/hooks/useServiceCategory";
import { useAuth } from "@/shared/context/AuthContext";
import { useState } from "react";
// Validation schema factory
const createServiceSchema = (t: (key: string) => string) =>
z.object({
slug: z.string().optional(),
prefix: z.string().optional(),
nameAm: z
.string()
.min(1, t("services.validation.nameAmRequired"))
.max(100, t("services.validation.nameMaxLength")),
nameEn: z
.string()
.min(1, t("services.validation.nameEnRequired"))
.max(100, t("services.validation.nameMaxLength")),
descriptionAm: z
.string()
.min(1, t("services.validation.descriptionAmRequired"))
.max(500, t("services.validation.descriptionMaxLength")),
descriptionEn: z
.string()
.min(1, t("services.validation.descriptionEnRequired"))
.max(500, t("services.validation.descriptionMaxLength")),
organizationId: z
.string()
.min(1, t("services.validation.organizationRequired")),
serviceCategoryId: z
.string()
.min(1, t("services.validation.categoryRequired")),
parentServiceId: z.string().optional(),
estimatedTime: z
.coerce.number()
.min(1, t("services.validation.estimatedTimeMin"))
.max(1440, t("services.validation.estimatedTimeMax")), // Max 24 hours in minutes
estimatedTimeUnit: z.nativeEnum(DateMeasurementUnit),
});
interface ServiceFormValues {
slug?: string;
prefix?: string;
nameAm: string;
nameEn: string;
descriptionAm: string;
descriptionEn: string;
organizationId: string;
serviceCategoryId: string;
parentServiceId?: string;
estimatedTime: number;
estimatedTimeUnit: DateMeasurementUnit;
}
export default function ServiceForm() {
const { id } = useParams<{ id: string }>(); // Get id from URL
const isEdit = !!id;
const { t } = useTranslation();
const localizedName = useLocalizedName();
const { user } = useAuth();
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: "";
// Fetch service categories
const { data: serviceCategoriesResponse } =
useServiceCategoryList(organizationId);
// Fetch parent services based on selected category
const { data: parentServicesResponse } = useServiceList(
organizationId,
selectedCategoryId || undefined
);
// Debug: Log the response to see its structure
console.log("Service Categories Response:", serviceCategoriesResponse);
console.log(
"serviceCategoriesResponse?.data:",
serviceCategoriesResponse?.data
);
// Extract service categories from response - handle different response structures
let serviceCategories = [];
if (serviceCategoriesResponse?.data) {
// Try different possible structures
if (Array.isArray(serviceCategoriesResponse.data)) {
serviceCategories = serviceCategoriesResponse.data;
} else if (serviceCategoriesResponse.data.data) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.data)
? serviceCategoriesResponse.data.data
: [];
} else if (serviceCategoriesResponse.data.items) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.items)
? serviceCategoriesResponse.data.items
: [];
}
}
console.log("Extracted Service Categories:", serviceCategories);
// Extract parent services
let parentServices = [];
if (parentServicesResponse?.data) {
if (Array.isArray(parentServicesResponse.data)) {
parentServices = parentServicesResponse.data;
} else if (parentServicesResponse.data.data) {
parentServices = Array.isArray(parentServicesResponse.data.data)
? parentServicesResponse.data.data
: [];
} else if (parentServicesResponse.data.items) {
parentServices = Array.isArray(parentServicesResponse.data.items)
? parentServicesResponse.data.items
: [];
}
}
// Fetch single service (for edit)
const { data: serviceDetail } = useService(id ?? "");
const { create: createMutation, update: updateMutation } =
useServiceMutations();
const initialValues: ServiceFormValues = {
slug: serviceDetail?.data?.slug || "",
prefix: serviceDetail?.data?.prefix || "",
nameAm: serviceDetail?.data?.name?.am || "",
nameEn: serviceDetail?.data?.name?.en || "",
descriptionAm: serviceDetail?.data?.description?.am || "",
descriptionEn: serviceDetail?.data?.description?.en || "",
organizationId: serviceDetail?.data?.organizationId || organizationId,
serviceCategoryId: serviceDetail?.data?.serviceCategoryId || "",
parentServiceId: serviceDetail?.data?.parentServiceId || undefined,
estimatedTime: serviceDetail?.data?.estimatedTime || 0,
estimatedTimeUnit:
serviceDetail?.data?.estimatedTimeUnit || DateMeasurementUnit.MINUTES,
};
// Update selected category when service detail loads or form changes
if (serviceDetail?.data?.serviceCategoryId && !selectedCategoryId) {
setSelectedCategoryId(serviceDetail.data.serviceCategoryId);
}
const fields: FormField<ServiceFormValues>[] = [
{
name: "slug",
label: t("services.slug", "Slug"),
type: "text",
placeholder: t("services.enterSlug", "Enter slug"),
required: false,
},
{
name: "prefix",
label: t("services.prefix", "Prefix"),
type: "text",
placeholder: t("services.enterPrefix", "Enter prefix"),
required: false,
},
{
name: "serviceCategoryId",
label: t("services.serviceCategoryField", "Service Category"),
type: "select",
placeholder: t(
"services.selectServiceCategory",
"Select Service Category"
),
required: true,
options: (() => {
const options = Array.isArray(serviceCategories)
? serviceCategories.map((category) => {
console.log("Processing category:", category);
return {
value: category.id,
label: localizedName(category.name),
};
})
: [];
console.log("Generated dropdown options:", options);
return options;
})(),
onChange: (value: string) => {
setSelectedCategoryId(value);
form.setValue("parentServiceId", undefined); // Reset parent service when category changes
},
},
{
name: "parentServiceId",
label: t("services.parentService", "Parent Service (Optional)"),
type: "select",
placeholder: t(
"services.parentServiceOptional",
"Parent Service (Optional)"
),
required: false,
options: parentServices.map(
(service: { id: string; name: { en: string; am: string } }) => ({
value: service.id,
label: localizedName(service.name),
})
),
},
{
name: "nameAm",
label: t("services.nameAm", "Name (Amharic)"),
type: "text",
placeholder: t("services.nameAmPlaceholder", "Enter Amharic name"),
required: true,
},
{
name: "nameEn",
label: t("services.nameEn", "Name (English)"),
type: "text",
placeholder: t("services.nameEnPlaceholder", "Enter English name"),
required: true,
},
{
name: "descriptionAm",
label: t("services.descriptionAm", "Description (Amharic)"),
type: "textarea",
placeholder: t(
"services.descriptionAmPlaceholder",
"Enter Amharic description"
),
required: true,
},
{
name: "descriptionEn",
label: t("services.descriptionEn", "Description (English)"),
type: "textarea",
placeholder: t(
"services.descriptionEnPlaceholder",
"Enter English description"
),
required: true,
},
{
name: "estimatedTime",
label: t("services.estimatedTime", "Estimated Time"),
type: "number",
placeholder: t("services.enterEstimatedTime", "Enter estimated time"),
},
{
name: "estimatedTimeUnit",
label: t("services.timeUnit", "Time Unit"),
type: "select",
options: Object.values(DateMeasurementUnit).map((unit) => ({
value: unit,
label: t(`services.timeUnits.${unit.toLowerCase()}`, unit),
})),
},
];
const form = useForm<ServiceFormValues>({
resolver: zodResolver(createServiceSchema(t)),
defaultValues: initialValues,
values: initialValues, // Important for Edit Mode
});
const handleSubmit = (values: ServiceFormValues) => {
const dto = {
id: isEdit ? id : undefined, // Include id for updates
slug: values.slug || "",
prefix: values.prefix || "",
name: {
am: values.nameAm,
en: values.nameEn,
},
description: {
am: values.descriptionAm,
en: values.descriptionEn,
},
organizationId: values.organizationId,
serviceCategoryId: values.serviceCategoryId,
parentServiceId: values.parentServiceId || undefined,
estimatedTime: Number(values.estimatedTime) || 0,
estimatedTimeUnit: values.estimatedTimeUnit,
};
if (isEdit) {
updateMutation.mutate(dto);
} else {
createMutation.mutate(dto);
}
};
return (
<GenericForm<ServiceFormValues>
form={form}
fields={fields}
onSubmit={handleSubmit}
submitButtonText={
isEdit
? t("services.updateService", "Update Service")
: t("services.createService", "Create Service")
}
title={
isEdit
? t("services.editTitle", "Edit Service")
: t("services.createTitle", "Create Service")
}
description={
isEdit
? t("services.editDescription", "Update the service information")
: t(
"services.createDescription",
"Add a new service to your organization"
)
}
successMessage={
isEdit
? t("services.updateSuccess", "Service updated successfully")
: t("services.createSuccess", "Service created successfully")
}
errorMessage={
isEdit
? t("services.updateError", "Failed to update service")
: t("services.createError", "Failed to create service")
}
/>
);
}

View File

@@ -0,0 +1,393 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
DownloadIcon,
EditIcon,
TrashIcon,
EyeIcon,
PlusIcon,
} from "lucide-react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { IServiceResponse } from "../../types/servicetypes";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useToast } from "@/shared/common/ui/use-toast";
import { ColumnDef } from "@tanstack/react-table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
useServiceList,
useServiceMutations,
} from "@/performance-management/hooks/useServiceHook";
import { useAuth } from "@/shared/context/AuthContext";
import { useLocalizedName } from "@/shared/common/localizedName";
const ServicesTable = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { toast } = useToast();
const [pageIndex, setPageIndex] = useState(0);
const [selectedService, setSelectedService] =
useState<IServiceResponse | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { user } = useAuth();
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const localizedName = useLocalizedName();
// ✅ SAFELY HANDLE employee ARRAY
// Use the organizationId directly from user's employee data
const { data, isLoading, isError, refetch } = useServiceList(
organizationId || ""
);
const { delete: deleteMutation } = useServiceMutations();
const handleDelete = async () => {
if (!selectedService) return;
deleteMutation.mutate(selectedService.id, {
onSuccess: () => {
toast({
title: t("services.deleteSuccess", "Deleted"),
description: t(
"services.deleteSuccessMessage",
"Service deleted successfully"
),
});
setIsDeleteDialogOpen(false);
setSelectedService(null);
refetch();
},
onError: () => {
toast({
title: t("common.error", "Error"),
description: t("services.deleteError", "Failed to delete service"),
variant: "destructive",
});
},
});
};
const services = (data?.data?.items || []).sort(
(a: IServiceResponse, b: IServiceResponse) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA; // Descending order (newest first)
}
);
const columns: ColumnDef<IServiceResponse>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.serviceName", "Service Name")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => localizedName(row.original.name),
size: 200,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const nameA = localizedName(rowA.original.name).toLowerCase();
const nameB = localizedName(rowB.original.name).toLowerCase();
return nameA.localeCompare(nameB);
},
},
{
accessorKey: "description",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.description", "Description")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => (
<div
className="max-w-[300px] truncate"
title={localizedName(row.original.description)}
>
{localizedName(row.original.description)}
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const descA = localizedName(rowA.original.description).toLowerCase();
const descB = localizedName(rowB.original.description).toLowerCase();
return descA.localeCompare(descB);
},
},
{
accessorKey: "estimatedTime",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.estimatedTime", "Estimated Time")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) =>
`${row.original.estimatedTime} ${t(
`services.timeUnits.${row.original.estimatedTimeUnit.toLowerCase()}`,
row.original.estimatedTimeUnit
)}`,
enableSorting: true,
sortingFn: (rowA, rowB) => {
return rowA.original.estimatedTime - rowB.original.estimatedTime;
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("services.createdAt", "Created At")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: " ↓"}
</Button>
);
},
cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.original.createdAt).getTime();
const dateB = new Date(rowB.original.createdAt).getTime();
return dateA - dateB;
},
},
{
id: "actions",
header: t("common.actions", "Actions"),
cell: ({ row }) => (
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(
`/performance-management/services/${row.original.id}`
)
}
>
<EyeIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("services.viewDetails", "View details")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(
`/performance-management/services/${row.original.id}/edit`
)
}
>
<EditIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit", "Edit")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedService(row.original);
setIsDeleteDialogOpen(true);
}}
disabled={deleteMutation.isPending}
>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete", "Delete")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
},
];
const extraToolbar = (
<div className="flex gap-2">
<Button
onClick={() => navigate(`/performance-management/services/new`)}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
<PlusIcon className="h-4 w-4 mr-2" />
{t("services.createService", "Create Service")}
</Button>
<Button
variant="outline"
onClick={() =>
toast({
title: t("services.exporting", "Exporting"),
description: t(
"services.preparingData",
"Preparing service data..."
),
})
}
>
<DownloadIcon className="h-4 w-4 mr-2" />
{t("services.exportAll", "Export All")}
</Button>
</div>
);
// ⬇️ LOADING UI
if (isLoading)
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
// ⬇️ ERROR UI
if (isError)
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
{t("services.loadError", "Failed to load services")}
</p>
<Button onClick={() => refetch()}>{t("common.retry", "Retry")}</Button>
</div>
);
return (
<div className="p-6">
<AdvancedTable<IServiceResponse, unknown>
columns={columns}
data={services}
tableName={t("services.title", "Service Management")}
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={services.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
{/* DELETE DIALOG */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("services.deleteTitle", "Confirm Deletion")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"services.deleteConfirmation",
'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
{ name: localizedName(selectedService?.name) }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
onClick={handleDelete}
>
{deleteMutation.isPending
? t("common.deleting", "Deleting...")
: t("common.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default ServicesTable;

View File

@@ -0,0 +1,201 @@
import { useState } from "react";
import { Input } from "@/shared/common/ui/input";
import { Search } from "lucide-react";
import { OrganizationList } from "./lists/OrganizationList";
import { UnitList } from "./lists/UnitList";
import { DepartmentList } from "./lists/DepartmentList";
import { PositionServicesList } from "./lists/PositionServicesList";
import { useOrganizationsData } from "@/user-management/userManagement/hooks/useOrganizationsData";
import { useUnitsData } from "@/user-management/userManagement/hooks/useUnitsData";
import { useDepartmentsData } from "@/user-management/userManagement/hooks/useDepartmentsData";
import { useSelectionHandlers } from "@/user-management/userManagement/handlers/useSelectionHandlers";
import { Breadcrumb } from "@/user-management/userManagement/components/Breadcrumb";
import { useAuth } from "@/shared/context/AuthContext";
import { useLocalizedName } from "@/shared/common/localizedName";
import { t } from "i18next";
import { AssignServiceDialog } from "./dialogs/AssignServiceDialog";
const ServiceAssignmentPage = () => {
const [searchQuery, setSearchQuery] = useState("");
const [unitSearchQuery, setUnitSearchQuery] = useState("");
const [departmentSearchQuery, setDepartmentSearchQuery] = useState("");
const { user } = useAuth();
const localizedName = useLocalizedName();
// Dialog state for service assignment
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [selectedPositionId, setSelectedPositionId] = useState<string>("");
const [selectedPositionName, setSelectedPositionName] = useState<string>("");
// Get organization ID from user
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
// Hooks for data loading
const { organizations, isLoading: orgsLoading } = useOrganizationsData({
currentOrgId: organizationId,
});
const {
selectedOrgId,
selectedUnitId,
selectedDepartmentId,
breadcrumb,
handleSelectOrganization,
handleSelectUnit,
handleSelectDepartment,
} = useSelectionHandlers(organizations, localizedName);
const [take] = useState(100);
const [skip] = useState(0);
const {
units,
isLoading: unitsLoading,
setUnits,
} = useUnitsData(selectedOrgId, { take, skip });
const {
departments,
isLoading: deptsLoading,
setDepartments,
positions,
} = useDepartmentsData(selectedUnitId);
// Handle assign service action
const handleAssignService = (positionId: string, positionName: string) => {
setSelectedPositionId(positionId);
setSelectedPositionName(positionName);
setIsAssignDialogOpen(true);
};
return (
<div className="h-full flex flex-col">
<div className="p-4 border-b">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-semibold text-gray-800">
{t("organization.serviceAssignment", "Service Assignment")}
</h1>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="Search organizations, units, departments..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="mt-4">
<Breadcrumb breadcrumb={breadcrumb} hasTeamMembers={false} />
</div>
</div>
{/* Position Services List */}
<div className="px-4 pb-4">
<PositionServicesList positionId={selectedDepartmentId} />
</div>
{/* Desktop view - grid layout */}
<div className="flex-1 p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("organization.organizations")}
</h3>
<OrganizationList
selectedOrgId={selectedOrgId}
organizations={organizations}
isLoading={orgsLoading}
searchQuery={searchQuery}
onSelectOrganization={(id: string) => {
setUnits([]);
setDepartments([]);
handleSelectOrganization(id);
}}
/>
</div>
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("contentManagement.Units")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t("search.searchUnits", "Search units...")}
className="pl-8 text-sm"
value={unitSearchQuery}
onChange={(e) => setUnitSearchQuery(e.target.value)}
/>
</div>
</div>
<UnitList
organizationId={selectedOrgId}
selectedUnitId={selectedUnitId}
onSelectUnit={(id: string) => {
setDepartments([]);
handleSelectUnit(id, units);
}}
units={units}
isLoading={unitsLoading}
searchQuery={unitSearchQuery}
/>
</div>
<div className="border rounded-md p-3 bg-white shadow-sm">
<h3 className="font-medium text-gray-700 mb-3 border-b pb-2">
{t("userIncoming.Departments")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t(
"search.searchDepartments",
"Search departments..."
)}
className="pl-8 text-sm"
value={departmentSearchQuery}
onChange={(e) => setDepartmentSearchQuery(e.target.value)}
/>
</div>
</div>
<DepartmentList
unitId={selectedUnitId}
selectedDepartmentId={selectedDepartmentId}
onSelectDepartment={(id: string) => {
handleSelectDepartment(id, departments);
}}
departments={departments}
positions={positions}
isLoading={deptsLoading}
searchQuery={departmentSearchQuery}
onAssignService={handleAssignService}
/>
</div>
</div>
{/* Assign Service Dialog */}
<AssignServiceDialog
isOpen={isAssignDialogOpen}
onClose={() => {
setIsAssignDialogOpen(false);
setSelectedPositionId("");
setSelectedPositionName("");
}}
positionId={selectedPositionId}
positionName={selectedPositionName}
organizationId={selectedOrgId}
/>
</div>
);
};
export default ServiceAssignmentPage;

View File

@@ -0,0 +1,595 @@
import { useState, useEffect, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Checkbox } from "@/shared/common/ui/checkbox";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import {
Search,
Loader2,
ChevronRight,
ChevronDown,
Folder,
FileText,
} from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import { t } from "i18next";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import {
assignServicesToPosition,
getAssignedServices,
} from "@/performance-management/services/api/serviceAssignmentService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useServiceCategoryList } from "@/performance-management/hooks/useServiceCategory";
import { useServiceList } from "@/performance-management/hooks/useServiceHook";
interface AssignServiceDialogProps {
isOpen: boolean;
onClose: () => void;
positionId: string;
positionName: string;
organizationId: string;
}
interface ServiceCategory {
id: string;
name: Record<string, string>;
description?: Record<string, string>;
}
interface Service {
id: string;
name: Record<string, string>;
description?: Record<string, string>;
serviceCategoryId?: string;
parentServiceId?: string;
childServices?: Service[];
}
export const AssignServiceDialog = ({
isOpen,
onClose,
positionId,
positionName,
organizationId,
}: AssignServiceDialogProps) => {
const { toast } = useToast();
const localizedName = useLocalizedName();
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set()
);
const [expandedServices, setExpandedServices] = useState<Set<string>>(
new Set()
);
const [alreadyAssignedServiceIds, setAlreadyAssignedServiceIds] = useState<
string[]
>([]);
// Fetch service categories
const { data: serviceCategoriesResponse, isLoading: categoriesLoading } =
useServiceCategoryList(organizationId);
// Fetch all services (without category filter for search)
const { data: allServicesResponse } = useServiceList(organizationId);
// Helper function to safely render text that might be localized
const renderText = (
text: Record<string, string> | string | undefined
): string => {
if (!text) return "";
if (typeof text === "string") return text;
if (typeof text === "object") {
const textObj = text as Record<string, string>;
if (textObj.en || textObj.am) {
return localizedName({ am: textObj.am || "", en: textObj.en || "" });
}
}
return String(text);
};
// Extract service categories
let serviceCategories: ServiceCategory[] = [];
if (serviceCategoriesResponse?.data) {
if (Array.isArray(serviceCategoriesResponse.data)) {
serviceCategories = serviceCategoriesResponse.data;
} else if (serviceCategoriesResponse.data.data) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.data)
? serviceCategoriesResponse.data.data
: [];
} else if (serviceCategoriesResponse.data.items) {
serviceCategories = Array.isArray(serviceCategoriesResponse.data.items)
? serviceCategoriesResponse.data.items
: [];
}
}
// Extract all services
let allServices: Service[] = [];
if (allServicesResponse?.data) {
if (Array.isArray(allServicesResponse.data)) {
allServices = allServicesResponse.data;
} else if (allServicesResponse.data.data) {
allServices = Array.isArray(allServicesResponse.data.data)
? allServicesResponse.data.data
: [];
} else if (allServicesResponse.data.items) {
allServices = Array.isArray(allServicesResponse.data.items)
? allServicesResponse.data.items
: [];
}
}
// Build parent-child relationships
const servicesWithChildren = allServices.map((service) => {
const childServices = allServices.filter(
(s) => s.parentServiceId === service.id
);
return {
...service,
childServices: childServices.length > 0 ? childServices : undefined,
};
});
// Fetch assigned service when dialog opens
const fetchAssignedService = useCallback(async () => {
try {
const response = await getAssignedServices(positionId);
let assignedServices = response.data?.data || response.data || [];
if (!Array.isArray(assignedServices)) {
assignedServices = assignedServices.items || [];
}
if (Array.isArray(assignedServices) && assignedServices.length > 0) {
const serviceIds = assignedServices
.map(
(service: { id?: string; serviceId?: string; firstId?: string }) =>
service.id || service.serviceId || service.firstId
)
.filter((id: string | undefined): id is string => !!id);
setSelectedServiceIds(serviceIds);
setAlreadyAssignedServiceIds(serviceIds);
// Auto-expand categories containing the selected services
serviceIds.forEach((serviceId: string) => {
const service = allServices.find((s) => s.id === serviceId);
if (service?.serviceCategoryId) {
setExpandedCategories((prev) =>
new Set(prev).add(service.serviceCategoryId!)
);
}
// Auto-expand parent services if this is a child service
if (service?.parentServiceId) {
setExpandedServices((prev) =>
new Set(prev).add(service.parentServiceId!)
);
}
});
} else {
setSelectedServiceIds([]);
setAlreadyAssignedServiceIds([]);
}
} catch (error) {
console.error("Failed to fetch assigned service:", error);
setSelectedServiceIds([]);
setAlreadyAssignedServiceIds([]);
}
}, [positionId, allServices]);
useEffect(() => {
if (isOpen && positionId) {
fetchAssignedService();
}
}, [isOpen, positionId, fetchAssignedService]);
const handleToggleService = (serviceId: string) => {
// Check if service is already assigned
if (alreadyAssignedServiceIds.includes(serviceId)) {
toast({
title: "Warning",
description: "Service already assigned to this position",
variant: "destructive",
});
return;
}
setSelectedServiceIds((prev) => {
if (prev.includes(serviceId)) {
return prev.filter((id) => id !== serviceId);
} else {
return [...prev, serviceId];
}
});
};
const handleSave = async () => {
// Filter out already assigned services
const newServiceIds = selectedServiceIds.filter(
(id) => !alreadyAssignedServiceIds.includes(id)
);
if (newServiceIds.length === 0) {
toast({
title: "Warning",
description: "Please select at least one new service to assign",
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await assignServicesToPosition(positionId, newServiceIds);
toast({
title: "Success",
description: `${newServiceIds.length} service(s) assigned to ${positionName} successfully`,
variant: "default",
});
onClose();
} catch (error: unknown) {
console.error("Failed to assign service:", error);
const errorMessage =
(
error as {
response?: { data?: { message?: string } };
message?: string;
}
).response?.data?.message ||
(error as { message?: string }).message ||
"Failed to assign service";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const handleClearSelection = () => {
setSelectedServiceIds([]);
};
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
const toggleService = (serviceId: string) => {
setExpandedServices((prev) => {
const newSet = new Set(prev);
if (newSet.has(serviceId)) {
newSet.delete(serviceId);
} else {
newSet.add(serviceId);
}
return newSet;
});
};
// Group services by category (only parent services)
const parentServicesByCategory = servicesWithChildren
.filter((service) => !service.parentServiceId)
.reduce((acc, service) => {
const categoryId = service.serviceCategoryId || "uncategorized";
if (!acc[categoryId]) {
acc[categoryId] = [];
}
acc[categoryId].push(service);
return acc;
}, {} as Record<string, Service[]>);
// Filter categories and services based on search
const filteredData = searchQuery
? {
categories: serviceCategories.filter((cat) =>
renderText(cat.name).toLowerCase().includes(searchQuery.toLowerCase())
),
services: servicesWithChildren.filter((service) => {
const searchLower = searchQuery.toLowerCase();
const serviceName = renderText(service.name).toLowerCase();
const serviceDesc = renderText(service.description).toLowerCase();
return (
serviceName.includes(searchLower) ||
serviceDesc.includes(searchLower)
);
}),
}
: { categories: serviceCategories, services: servicesWithChildren };
// Auto-expand categories when searching
useEffect(() => {
if (searchQuery && filteredData.services.length > 0) {
const categoriesToExpand = new Set<string>();
const servicesToExpand = new Set<string>();
filteredData.services.forEach((service) => {
if (service.serviceCategoryId) {
categoriesToExpand.add(service.serviceCategoryId);
}
// If it's a child service, expand its parent
if (service.parentServiceId) {
servicesToExpand.add(service.parentServiceId);
}
});
setExpandedCategories(categoriesToExpand);
setExpandedServices(servicesToExpand);
}
}, [searchQuery, filteredData.services]);
const renderService = (service: Service, isChild: boolean = false) => {
const isExpanded = expandedServices.has(service.id);
const hasChildren =
service.childServices && service.childServices.length > 0;
const isAlreadyAssigned = alreadyAssignedServiceIds.includes(service.id);
return (
<div key={service.id} className="space-y-1">
<div
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
isChild ? "ml-6" : ""
} ${
selectedServiceIds.includes(service.id)
? "border-purple-500 bg-purple-50"
: isAlreadyAssigned
? "border-gray-300 bg-gray-50 opacity-60"
: "border-gray-200 hover:bg-gray-50"
} ${hasChildren ? "cursor-pointer" : ""}`}
>
{hasChildren && (
<div
onClick={() => toggleService(service.id)}
className="cursor-pointer pt-1"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</div>
)}
<Checkbox
checked={selectedServiceIds.includes(service.id)}
onCheckedChange={() => handleToggleService(service.id)}
id={`service-${service.id}`}
className="mt-1"
disabled={isAlreadyAssigned}
/>
<FileText className="h-4 w-4 text-blue-500 mt-1" />
<div
className="flex-1"
onClick={() =>
!isAlreadyAssigned && handleToggleService(service.id)
}
>
<Label
htmlFor={`service-${service.id}`}
className={`cursor-pointer block ${
isAlreadyAssigned ? "cursor-not-allowed" : ""
}`}
>
<div className="font-medium text-gray-900">
{renderText(service.name)}
{isAlreadyAssigned && (
<span className="ml-2 text-xs text-gray-500">
(Already Assigned)
</span>
)}
</div>
{service.description && (
<div className="text-sm text-gray-600 mt-1 line-clamp-2">
{renderText(service.description)}
</div>
)}
</Label>
</div>
</div>
{/* Render child services */}
{hasChildren && isExpanded && (
<div className="ml-6 space-y-2 mt-1">
{service.childServices!.map((childService) =>
renderService(childService, true)
)}
</div>
)}
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("assignServices", "Assign Services")}</DialogTitle>
<DialogDescription>
{t(
"assignServicesDescription",
`Select one or more services to assign to position: ${positionName}`
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Search */}
<div className="relative shrink-0">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder={t(
"searchServices",
"Search services or categories..."
)}
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{/* Tree View */}
<ScrollArea className="flex-1 border rounded-md p-2 min-h-0">
<div className="max-h-[400px]">
{categoriesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-purple-600" />
</div>
) : filteredData.categories.length === 0 &&
filteredData.services.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{t("noServicesFound", "No services found")}
</div>
) : (
<div>
<div className="space-y-1 pr-3">
{filteredData.categories.map((category) => {
const categoryServices = searchQuery
? filteredData.services.filter(
(s) =>
s.serviceCategoryId === category.id &&
!s.parentServiceId
)
: parentServicesByCategory[category.id] || [];
const isExpanded = expandedCategories.has(category.id);
if (searchQuery && categoryServices.length === 0) {
return null;
}
return (
<div key={category.id} className="space-y-1">
{/* Category Header */}
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-100 cursor-pointer"
onClick={() => toggleCategory(category.id)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Folder className="h-4 w-4 text-purple-500" />
<span className="font-medium text-gray-900">
{renderText(category.name)}
</span>
<span className="text-xs text-gray-500 ml-auto">
({categoryServices.length})
</span>
</div>
{/* Services under this category */}
{isExpanded && categoryServices.length > 0 && (
<div className="ml-6 space-y-2 mt-1">
{categoryServices.map((service) =>
renderService(service)
)}
</div>
)}
</div>
);
})}
{/* Uncategorized services */}
{parentServicesByCategory["uncategorized"] &&
parentServicesByCategory["uncategorized"].length > 0 && (
<div className="space-y-1">
<div
className="flex items-center gap-2 p-2 rounded-md hover:bg-gray-100 cursor-pointer"
onClick={() => toggleCategory("uncategorized")}
>
{expandedCategories.has("uncategorized") ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Folder className="h-4 w-4 text-gray-400" />
<span className="font-medium text-gray-700">
Uncategorized
</span>
<span className="text-xs text-gray-500 ml-auto">
(
{parentServicesByCategory["uncategorized"].length}
)
</span>
</div>
{expandedCategories.has("uncategorized") && (
<div className="ml-6 space-y-2 mt-1">
{parentServicesByCategory["uncategorized"].map(
(service) => renderService(service)
)}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
</ScrollArea>
{/* Selected service info */}
<div className="flex items-center justify-between text-sm shrink-0">
<div className="text-gray-600">
{selectedServiceIds.length > 0
? t(
"servicesSelected",
`${selectedServiceIds.length} service(s) selected`
)
: t("noServiceSelected", "No services selected")}
</div>
{selectedServiceIds.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearSelection}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t("clearSelection", "Clear Selection")}
</Button>
)}
</div>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose} disabled={isSaving}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleSave}
disabled={isSaving || selectedServiceIds.length === 0}
className="bg-purple-600 hover:bg-purple-700"
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("saving", "Saving...")}
</>
) : (
t("assignServices", "Assign Services")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,192 @@
import {
ChevronDown,
ChevronRight,
Briefcase,
MoreVertical,
} from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import { PositionDto } from "@/user-management/dto/positions/positionDto";
interface DepartmentListProps {
unitId: string;
selectedDepartmentId: string;
onSelectDepartment: (id: string) => void;
departments: any[];
positions: PositionDto[];
isLoading: boolean;
searchQuery: string;
onAssignService?: (positionId: string, positionName: string) => void;
}
export const DepartmentList = ({
unitId,
selectedDepartmentId,
onSelectDepartment,
departments,
positions,
isLoading,
searchQuery,
onAssignService,
}: DepartmentListProps) => {
const localizedName = useLocalizedName();
const [expandedPositions, setExpandedPositions] = useState<Set<string>>(
new Set()
);
const togglePosition = (positionId: string) => {
setExpandedPositions((prev) => {
const newSet = new Set(prev);
if (newSet.has(positionId)) {
newSet.delete(positionId);
} else {
newSet.add(positionId);
}
return newSet;
});
};
// Recursive function to render position hierarchy
const renderPosition = (position: PositionDto, level: number = 0) => {
const hasSubPositions =
position.subPositions && position.subPositions.length > 0;
const isExpanded = expandedPositions.has(position.id);
return (
<div key={position.id} className="space-y-1">
<div
className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
selectedDepartmentId === position.id
? "bg-purple-50 border-purple-500"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
style={{ marginLeft: `${level * 16}px` }}
>
{hasSubPositions ? (
<button
onClick={() => togglePosition(position.id)}
className="p-1 hover:bg-gray-200 rounded flex-shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
) : (
<div className="w-6 flex-shrink-0" />
)}
<button
onClick={() => onSelectDepartment(position.id)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
>
<Briefcase className="h-4 w-4 flex-shrink-0" />
<span className="font-medium truncate">
{localizedName(position.name)}
</span>
</button>
{hasSubPositions && (
<span className="text-xs text-gray-500 px-2 flex-shrink-0">
{position.subPositions.length}
</span>
)}
{onAssignService && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0"
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
onAssignService(position.id, localizedName(position.name))
}
>
{t("assignService", "Assign Service")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Render sub-positions recursively */}
{isExpanded && hasSubPositions && (
<div className="space-y-1">
{position.subPositions.map((subPosition) =>
renderPosition(subPosition, level + 1)
)}
</div>
)}
</div>
);
};
if (!unitId) {
return (
<div className="text-center py-8 text-gray-500">
{t("selectUnitFirst", "Select a unit first")}
</div>
);
}
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
// Filter positions based on search query
const filterPositions = (positions: PositionDto[]): PositionDto[] => {
if (!searchQuery) return positions;
return positions.filter((position) => {
const matchesSearch = localizedName(position.name)
.toLowerCase()
.includes(searchQuery.toLowerCase());
// Also check sub-positions
const hasMatchingSubPosition =
position.subPositions &&
filterPositions(position.subPositions).length > 0;
return matchesSearch || hasMatchingSubPosition;
});
};
const filteredPositions = filterPositions(positions);
if (filteredPositions.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
{t("noPositionsFound", "No positions found")}
</div>
);
}
return (
<div className="space-y-2">
{filteredPositions.map((position) => renderPosition(position))}
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { Building2 } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Organization {
id: string;
name: {
en: string;
am: string;
};
}
interface OrganizationListProps {
selectedOrgId: string;
organizations: Organization[];
isLoading: boolean;
searchQuery: string;
onSelectOrganization: (id: string) => void;
}
export const OrganizationList = ({
selectedOrgId,
organizations,
isLoading,
searchQuery,
onSelectOrganization,
}: OrganizationListProps) => {
const localizedName = useLocalizedName();
const filteredOrganizations = organizations.filter((org) =>
localizedName(org.name).toLowerCase().includes(searchQuery.toLowerCase())
);
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (filteredOrganizations.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No organizations found
</div>
);
}
return (
<div className="space-y-2">
{filteredOrganizations.map((org) => (
<button
key={org.id}
onClick={() => onSelectOrganization(org.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedOrgId === org.id
? "bg-purple-50 border-purple-500 text-purple-700"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4" />
<span className="font-medium">{localizedName(org.name)}</span>
</div>
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,360 @@
import React, { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import {
MoreVertical,
Clock,
ChevronDown,
ChevronRight,
Trash2,
} from "lucide-react";
import {
getPositionServices,
deletePositionService,
} from "@/performance-management/services/api/serviceAssignmentService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useToast } from "@/shared/common/ui/use-toast";
import { CreateWorkingHoursModal } from "@/performance-management/components/WorkingHours/CreateWorkingHoursModal";
import { EditWorkingHoursModal } from "@/performance-management/components/WorkingHours/EditWorkingHoursModal";
import { WorkingDaysTable } from "@/performance-management/components/WorkingHours/WorkingDaysTable";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import { WorkingHoursResponse } from "@/performance-management/types/workingHoursTypes";
import { t } from "i18next";
interface PositionServicesListProps {
positionId: string;
}
export const PositionServicesList = ({
positionId,
}: PositionServicesListProps) => {
const [services, setServices] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isWorkingHoursModalOpen, setIsWorkingHoursModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedPositionServiceId, setSelectedPositionServiceId] =
useState<string>("");
const [editingWorkingDay, setEditingWorkingDay] =
useState<WorkingHoursResponse | null>(null);
const [expandedServiceId, setExpandedServiceId] = useState<string | null>(
null
);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [deletingServiceId, setDeletingServiceId] = useState<string | null>(
null
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeletingService, setIsDeletingService] = useState(false);
const localizedName = useLocalizedName();
const { toast } = useToast();
const { delete: deleteWorkingHours } = useWorkingHoursMutations();
const handleCreateWorkingHours = (positionServiceId: string) => {
setSelectedPositionServiceId(positionServiceId);
setIsWorkingHoursModalOpen(true);
};
const handleWorkingHoursSuccess = () => {
setIsWorkingHoursModalOpen(false);
setSelectedPositionServiceId("");
setRefreshTrigger((prev) => prev + 1);
};
const handleEditWorkingDay = (workingDay: WorkingHoursResponse) => {
setEditingWorkingDay(workingDay);
setIsEditModalOpen(true);
};
const handleEditSuccess = () => {
setIsEditModalOpen(false);
setEditingWorkingDay(null);
setRefreshTrigger((prev) => prev + 1);
};
const handleDeleteWorkingDay = async (id: string) => {
deleteWorkingHours.mutate(id, {
onSuccess: () => {
toast({
title: "Success",
description: "Working hours deleted successfully",
});
setRefreshTrigger((prev) => prev + 1);
},
onError: (error: any) => {
toast({
title: "Error",
description:
error?.response?.data?.message || "Failed to delete working hours",
variant: "destructive",
});
},
});
};
const toggleExpand = (serviceId: string) => {
setExpandedServiceId(expandedServiceId === serviceId ? null : serviceId);
};
const handleDeleteService = (positionServiceId: string) => {
setDeletingServiceId(positionServiceId);
setIsDeleteDialogOpen(true);
};
const confirmDeleteService = async () => {
if (!deletingServiceId) return;
setIsDeletingService(true);
try {
await deletePositionService(deletingServiceId);
toast({
title: "Success",
description: "Service removed from position successfully",
});
// Refresh the services list
const response = await getPositionServices(positionId);
const data = response.data;
if (data && Array.isArray(data.items)) {
setServices(data.items);
} else if (Array.isArray(data)) {
setServices(data);
} else {
setServices([]);
}
setIsDeleteDialogOpen(false);
setDeletingServiceId(null);
} catch (error: any) {
toast({
title: "Error",
description:
error?.response?.data?.message || "Failed to remove service",
variant: "destructive",
});
} finally {
setIsDeletingService(false);
}
};
useEffect(() => {
const fetchServices = async () => {
if (!positionId) {
setServices([]);
return;
}
setIsLoading(true);
try {
const response = await getPositionServices(positionId);
// Handle both array and single object response
const data = response.data;
if (data && Array.isArray(data.items)) {
setServices(data.items);
} else if (Array.isArray(data)) {
setServices(data);
} else if (data) {
setServices([data]);
} else {
setServices([]);
}
} catch (error) {
console.error("Failed to fetch position services", error);
setServices([]);
} finally {
setIsLoading(false);
}
};
fetchServices();
}, [positionId]);
if (!positionId) {
return null;
}
if (isLoading) {
return (
<div className="space-y-2 border rounded-md p-4 bg-white mt-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
if (services.length === 0) {
return (
<div className="text-center py-8 text-gray-500 border rounded-md bg-white mt-4">
{t("noServicesFound", "No services found for this position")}
</div>
);
}
return (
<div className="border rounded-md bg-white overflow-hidden mt-4 shadow-sm">
<div className="p-3 border-b bg-gray-50">
<h3 className="font-medium text-gray-700">
{t("associatedServices", "Associated Services")}
</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>{t("name", "Name")}</TableHead>
<TableHead>{t("slug", "Slug")}</TableHead>
<TableHead>{t("description", "Description")}</TableHead>
<TableHead className="text-right">
{t("actions", "Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((positionService) => (
<React.Fragment key={positionService.id}>
<TableRow className="cursor-pointer hover:bg-gray-50">
<TableCell onClick={() => toggleExpand(positionService.id)}>
<Button variant="ghost" size="sm" className="p-0 h-6 w-6">
{expandedServiceId === positionService.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</TableCell>
<TableCell
className="font-medium"
onClick={() => toggleExpand(positionService.id)}
>
{localizedName(positionService.service?.name)}
</TableCell>
<TableCell onClick={() => toggleExpand(positionService.id)}>
{positionService.service?.slug}
</TableCell>
<TableCell onClick={() => toggleExpand(positionService.id)}>
{localizedName(positionService.service?.description)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
handleCreateWorkingHours(positionService.id)
}
>
<Clock className="h-4 w-4 mr-2" />
{t("createWorkingHours", "Create Working Hours")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteService(positionService.id)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
{t("removeService", "Remove Service")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{expandedServiceId === positionService.id && (
<TableRow>
<TableCell colSpan={5} className="bg-gray-50 p-4">
<div className="space-y-2">
<h4 className="font-semibold text-sm text-gray-700">
{t("workingDays", "Working Days")}
</h4>
<WorkingDaysTable
positionServiceId={positionService.id}
onEdit={handleEditWorkingDay}
onDelete={handleDeleteWorkingDay}
refreshTrigger={refreshTrigger}
/>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
))}
</TableBody>
</Table>
{/* Create Working Hours Modal */}
<CreateWorkingHoursModal
open={isWorkingHoursModalOpen}
onClose={() => {
setIsWorkingHoursModalOpen(false);
setSelectedPositionServiceId("");
}}
onSuccess={handleWorkingHoursSuccess}
positionServiceId={selectedPositionServiceId}
/>
{/* Edit Working Hours Modal */}
<EditWorkingHoursModal
open={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setEditingWorkingDay(null);
}}
onSuccess={handleEditSuccess}
workingDay={editingWorkingDay}
/>
{/* Delete Service Confirmation Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Service from Position</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove this service from the position?
This will also delete all associated working hours. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingService}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteService}
disabled={isDeletingService}
className="bg-red-600 hover:bg-red-700"
>
{isDeletingService ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { Building } from "lucide-react";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Unit {
id: string;
name: {
en: string;
am: string;
};
}
interface UnitListProps {
organizationId: string;
selectedUnitId: string;
onSelectUnit: (id: string) => void;
units: Unit[];
isLoading: boolean;
searchQuery: string;
}
export const UnitList = ({
organizationId,
selectedUnitId,
onSelectUnit,
units,
isLoading,
searchQuery,
}: UnitListProps) => {
const localizedName = useLocalizedName();
const filteredUnits = units.filter((unit) =>
localizedName(unit.name).toLowerCase().includes(searchQuery.toLowerCase())
);
if (!organizationId) {
return (
<div className="text-center py-8 text-gray-500">
Select an organization first
</div>
);
}
if (isLoading) {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (filteredUnits.length === 0) {
return <div className="text-center py-8 text-gray-500">No units found</div>;
}
return (
<div className="space-y-2">
{filteredUnits.map((unit) => (
<button
key={unit.id}
onClick={() => onSelectUnit(unit.id)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedUnitId === unit.id
? "bg-purple-50 border-purple-500 text-purple-700"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="flex items-center gap-2">
<Building className="h-4 w-4" />
<span className="font-medium">{localizedName(unit.name)}</span>
</div>
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,195 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/shared/context/AuthContext";
import { GenericForm } from "@/performance-management/utils/generic-form";
import { FormField } from "@/performance-management/utils/shared-field-types";
import { useServiceCategoryMutations } from "@/performance-management/hooks/useServiceCategory";
import {
CreateServiceCategoryRequest,
ServiceCategory,
} from "@/performance-management/services/api/serviceCategoryService";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";
// Validation schema factory
const createServiceCategorySchema = (t: (key: string) => string) =>
z.object({
slug: z.string().optional(),
nameEn: z
.string()
.min(1, t("serviceCategory.validation.nameEnRequired"))
.max(100, t("serviceCategory.validation.nameMaxLength")),
nameAm: z
.string()
.min(1, t("serviceCategory.validation.nameAmRequired"))
.max(100, t("serviceCategory.validation.nameMaxLength")),
descriptionEn: z
.string()
.min(1, t("serviceCategory.validation.descriptionEnRequired"))
.max(500, t("serviceCategory.validation.descriptionMaxLength")),
descriptionAm: z
.string()
.min(1, t("serviceCategory.validation.descriptionAmRequired"))
.max(500, t("serviceCategory.validation.descriptionMaxLength")),
});
interface ServiceCategoryFormValues {
slug?: string;
nameEn: string;
nameAm: string;
descriptionEn: string;
descriptionAm: string;
}
interface ServiceCategoryFormProps {
category?: ServiceCategory;
onSuccess?: () => void;
onCancel?: () => void;
}
export const ServiceCategoryForm = ({
category,
onSuccess,
onCancel,
}: ServiceCategoryFormProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useAuth();
const { create, update } = useServiceCategoryMutations();
const organizationId = user?.employee?.[0]?.organizationId;
const isEditing = !!category;
const form = useForm<ServiceCategoryFormValues>({
resolver: zodResolver(createServiceCategorySchema(t)),
defaultValues: {
slug: category?.slug || "",
nameEn: category?.name?.en || "",
nameAm: category?.name?.am || "",
descriptionEn: category?.description?.en || "",
descriptionAm: category?.description?.am || "",
},
});
// Reset form when category changes
useEffect(() => {
if (category) {
form.reset({
slug: category.slug,
nameEn: category.name?.en || "",
nameAm: category.name?.am || "",
descriptionEn: category.description?.en || "",
descriptionAm: category.description?.am || "",
});
}
}, [category, form]);
const serviceCategoryFormFields: FormField<ServiceCategoryFormValues>[] = [
{
name: "slug",
label: t("serviceCategory.slug"),
type: "text",
placeholder: t("serviceCategory.slugPlaceholder"),
description: t("serviceCategory.slugDescription"),
required: false,
},
{
name: "nameEn",
label: t("serviceCategory.nameEn"),
type: "text",
placeholder: t("serviceCategory.nameEnPlaceholder"),
required: true,
},
{
name: "nameAm",
label: t("serviceCategory.nameAm"),
type: "text",
placeholder: t("serviceCategory.nameAmPlaceholder"),
required: true,
},
{
name: "descriptionEn",
label: t("serviceCategory.descriptionEn"),
type: "textarea",
placeholder: t("serviceCategory.descriptionEnPlaceholder"),
required: true,
rows: 4,
},
{
name: "descriptionAm",
label: t("serviceCategory.descriptionAm"),
type: "textarea",
placeholder: t("serviceCategory.descriptionAmPlaceholder"),
required: true,
rows: 4,
},
];
const handleSubmit = async (values: ServiceCategoryFormValues) => {
if (!organizationId) {
throw new Error(t("serviceCategory.organizationRequired"));
}
const requestData: CreateServiceCategoryRequest = {
slug: values.slug || "",
name: {
en: values.nameEn,
am: values.nameAm,
},
description: {
en: values.descriptionEn,
am: values.descriptionAm,
},
organizationId,
};
if (isEditing && category) {
await update.mutateAsync({ id: category.id, data: requestData });
} else {
await create.mutateAsync(requestData);
}
onSuccess?.();
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
navigate(-1);
}
};
return (
<GenericForm<ServiceCategoryFormValues>
form={form}
fields={serviceCategoryFormFields}
onSubmit={handleSubmit}
onCancel={handleCancel}
isSubmitting={create.isPending || update.isPending}
submitButtonText={
isEditing
? t("serviceCategory.updateCategory")
: t("serviceCategory.createCategory")
}
cancelButtonText={t("common.cancel")}
successMessage={
isEditing
? t("serviceCategory.updateSuccess")
: t("serviceCategory.createSuccess")
}
errorMessage={
isEditing
? t("serviceCategory.updateError")
: t("serviceCategory.createError")
}
title={
isEditing
? t("serviceCategory.editTitle")
: t("serviceCategory.createTitle")
}
/>
);
};

View File

@@ -0,0 +1,308 @@
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { useLocalizedName } from "@/shared/common/localizedName";
import { ColumnDef } from "@tanstack/react-table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Textarea } from "@/shared/common/ui/textarea";
import {
useServiceCategoryList,
useServiceCategoryMutations,
} from "../../hooks/useServiceCategory";
import { ServiceCategory } from "../../services/api/serviceCategoryService";
interface ServiceCategoryListProps {
organizationId: string;
extraToolbar?: React.ReactNode;
}
export const ServiceCategoryList = ({
organizationId,
extraToolbar,
}: ServiceCategoryListProps) => {
const [pageIndex, setPageIndex] = useState(0);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [selectedCategory, setSelectedCategory] =
useState<ServiceCategory | null>(null);
const localizedName = useLocalizedName();
// Form state
const [formData, setFormData] = useState({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
const { data, isLoading, isError, refetch } =
useServiceCategoryList(organizationId);
const { update, delete: deleteCategory } = useServiceCategoryMutations();
const handleEdit = (category: ServiceCategory) => {
setSelectedCategory(category);
setFormData({
slug: category.slug,
name: category.name,
description: category.description,
});
setIsEditOpen(true);
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!organizationId || !selectedCategory) return;
update.mutate(
{
id: selectedCategory.id,
data: {
...formData,
organizationId,
},
},
{
onSuccess: () => {
setIsEditOpen(false);
setSelectedCategory(null);
setFormData({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
},
}
);
};
const handleDeleteClick = (category: ServiceCategory) => {
setSelectedCategory(category);
setIsDeleteOpen(true);
};
const handleDelete = () => {
if (!selectedCategory) return;
deleteCategory.mutate(selectedCategory.id, {
onSuccess: () => {
setIsDeleteOpen(false);
setSelectedCategory(null);
},
});
};
const columns: ColumnDef<ServiceCategory>[] = [
{
accessorKey: "slug",
header: "Slug",
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => localizedName(row.original.name),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => localizedName(row.original.description),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteClick(row.original)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
),
},
];
if (isLoading)
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
if (isError)
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
Failed to load service categories
</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
);
const serviceCategories = data?.data?.items || [];
return (
<div className="p-6">
<AdvancedTable<ServiceCategory, unknown>
columns={columns}
data={serviceCategories}
tableName="Service Categories"
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={serviceCategories.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Service Category</DialogTitle>
</DialogHeader>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<Label>Slug</Label>
<Input
value={formData.slug}
onChange={(e) =>
setFormData({ ...formData, slug: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Name (English)</Label>
<Input
value={formData.name.en}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, en: e.target.value },
})
}
required
/>
</div>
<div>
<Label>Name (Amharic)</Label>
<Input
value={formData.name.am}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, am: e.target.value },
})
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Description (English)</Label>
<Textarea
value={formData.description.en}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
en: e.target.value,
},
})
}
required
/>
</div>
<div>
<Label>Description (Amharic)</Label>
<Textarea
value={formData.description.am}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
am: e.target.value,
},
})
}
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsEditOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={update.isPending}>
{update.isPending ? "Updating..." : "Update"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Service Category</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "
{selectedCategory && localizedName(selectedCategory.name)}"? This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleteCategory.isPending}
className="bg-destructive text-white hover:bg-destructive/90"
>
{deleteCategory.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnitContext } from "@/shared/context/UnitContext";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Textarea } from "@/shared/common/ui/textarea";
import { useServiceCategoryMutations } from "../../hooks/useServiceCategory";
import { ServiceCategoryList } from "./ServiceCategoryList";
export const ServiceCategoryPage = () => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const { user } = useAuth();
// Form state
const [formData, setFormData] = useState({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
const organizationId =
user?.employee?.length && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const unitContext = useUnitContext();
const unitsQuery = unitContext?.getList(organizationId!, {
take: 300,
skip: 0,
});
const { create } = useServiceCategoryMutations();
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!organizationId) return;
create.mutate(
{
...formData,
organizationId: organizationId,
},
{
onSuccess: () => {
setIsCreateOpen(false);
setFormData({
slug: "",
name: { am: "", en: "" },
description: { am: "", en: "" },
});
},
}
);
};
const extraToolbar = (
<div className="flex gap-2">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="bg-gradient-to-r from-purple-500 to-violet-600 text-white">
<PlusIcon className="h-4 w-4 mr-2" />
Create Service Category
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Service Category</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<Label>Slug</Label>
<Input
value={formData.slug}
onChange={(e) =>
setFormData({ ...formData, slug: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Name (English)</Label>
<Input
value={formData.name.en}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, en: e.target.value },
})
}
required
/>
</div>
<div>
<Label>Name (Amharic)</Label>
<Input
value={formData.name.am}
onChange={(e) =>
setFormData({
...formData,
name: { ...formData.name, am: e.target.value },
})
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Description (English)</Label>
<Textarea
value={formData.description.en}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
en: e.target.value,
},
})
}
required
/>
</div>
<div>
<Label>Description (Amharic)</Label>
<Textarea
value={formData.description.am}
onChange={(e) =>
setFormData({
...formData,
description: {
...formData.description,
am: e.target.value,
},
})
}
required
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? "Creating..." : "Create"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
if (!organizationId) {
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">No organization found</p>
</div>
);
}
return (
<ServiceCategoryList
organizationId={organizationId}
extraToolbar={extraToolbar}
/>
);
};

View File

@@ -0,0 +1,226 @@
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { toast } from "sonner";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import { CreateWorkingHoursDto, WeekDay } from "../../types/workingHoursTypes";
interface CreateWorkingHoursModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
positionServiceId?: string;
}
const weekDays: (WeekDay | "all")[] = [
"all",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
export const CreateWorkingHoursModal = ({
open,
onClose,
onSuccess,
positionServiceId = "",
}: CreateWorkingHoursModalProps) => {
const { create } = useWorkingHoursMutations();
const [selectedWeekDay, setSelectedWeekDay] = useState<WeekDay | "all">(
"all"
);
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<CreateWorkingHoursDto>({
defaultValues: {
slug: "",
weekDay: "monday",
positionServiceId: positionServiceId,
startTime: "09:00",
endTime: "17:00",
},
});
// Update form when positionServiceId prop changes
useEffect(() => {
if (positionServiceId) {
setValue("positionServiceId", positionServiceId);
}
}, [positionServiceId, setValue]);
const onSubmit = async (data: CreateWorkingHoursDto) => {
// If "all" is selected, create working hours for all days
if (selectedWeekDay === "all") {
const allDays: WeekDay[] = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
let successCount = 0;
let errorCount = 0;
for (const day of allDays) {
const payload = {
...data,
weekDay: day,
};
try {
await create.mutateAsync(payload);
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to create working hours for ${day}:`, error);
}
}
if (successCount > 0) {
toast.success(`Working hours created for ${successCount} day(s)${errorCount > 0 ? `, ${errorCount} failed` : ""}`);
reset();
onClose();
onSuccess();
} else {
toast.error("Failed to create working hours for all days");
}
} else {
// Single day creation
const payload = {
...data,
weekDay: selectedWeekDay as WeekDay,
};
create.mutate(payload, {
onSuccess: () => {
reset();
onClose();
onSuccess();
},
});
}
};
const handleClose = () => {
reset();
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Working Hours</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
{...register("slug", { required: "Slug is required" })}
placeholder="e.g., morning-shift"
/>
{errors.slug && (
<p className="text-sm text-red-500">{errors.slug.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="weekDay">Week Day</Label>
<Select
value={selectedWeekDay}
onValueChange={(value) =>
setSelectedWeekDay(value as WeekDay | "all")
}
>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weekDays.map((day) => (
<SelectItem key={day} value={day}>
<span className="capitalize">
{day === "all" ? "All Days" : day}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startTime">Start Time</Label>
<Input
id="startTime"
type="time"
{...register("startTime", {
required: "Start time is required",
})}
/>
{errors.startTime && (
<p className="text-sm text-red-500">
{errors.startTime.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="endTime">End Time</Label>
<Input
id="endTime"
type="time"
{...register("endTime", { required: "End time is required" })}
/>
{errors.endTime && (
<p className="text-sm text-red-500">{errors.endTime.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={create.isPending}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
{create.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useWorkingHoursMutations } from "@/performance-management/hooks/useWorkingHoursHook";
import {
CreateWorkingHoursDto,
WeekDay,
WorkingHoursResponse,
} from "../../types/workingHoursTypes";
interface EditWorkingHoursModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
workingDay: WorkingHoursResponse | null;
}
const weekDays: WeekDay[] = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
export const EditWorkingHoursModal = ({
open,
onClose,
onSuccess,
workingDay,
}: EditWorkingHoursModalProps) => {
const { update } = useWorkingHoursMutations();
const [selectedWeekDay, setSelectedWeekDay] = useState<WeekDay>("monday");
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<CreateWorkingHoursDto>({
defaultValues: {
slug: "",
weekDay: "monday",
positionServiceId: "",
startTime: "09:00",
endTime: "17:00",
},
});
// Update form when workingDay changes
useEffect(() => {
if (workingDay) {
setValue("slug", workingDay.slug);
setValue("positionServiceId", workingDay.positionServiceId);
setValue("startTime", workingDay.startTime.substring(0, 5)); // Remove seconds
setValue("endTime", workingDay.endTime.substring(0, 5)); // Remove seconds
setSelectedWeekDay(workingDay.weekDay);
}
}, [workingDay, setValue]);
const onSubmit = async (data: CreateWorkingHoursDto) => {
if (!workingDay?.id) return;
const payload = {
...data,
weekDay: selectedWeekDay,
};
update.mutate(
{ id: workingDay.id, data: payload },
{
onSuccess: () => {
reset();
onClose();
onSuccess();
},
}
);
};
const handleClose = () => {
reset();
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Edit Working Hours</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
{...register("slug", { required: "Slug is required" })}
placeholder="e.g., morning-shift"
/>
{errors.slug && (
<p className="text-sm text-red-500">{errors.slug.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="weekDay">Week Day</Label>
<Select
value={selectedWeekDay}
onValueChange={(value) => setSelectedWeekDay(value as WeekDay)}
>
<SelectTrigger>
<SelectValue placeholder="Select a day" />
</SelectTrigger>
<SelectContent>
{weekDays.map((day) => (
<SelectItem key={day} value={day}>
<span className="capitalize">{day}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startTime">Start Time</Label>
<Input
id="startTime"
type="time"
{...register("startTime", {
required: "Start time is required",
})}
/>
{errors.startTime && (
<p className="text-sm text-red-500">
{errors.startTime.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="endTime">End Time</Label>
<Input
id="endTime"
type="time"
{...register("endTime", { required: "End time is required" })}
/>
{errors.endTime && (
<p className="text-sm text-red-500">{errors.endTime.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
type="submit"
disabled={update.isPending}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
{update.isPending ? "Updating..." : "Update"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/common/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Edit, Trash2 } from "lucide-react";
import { getWorkingHoursByPositionServiceId } from "@/performance-management/services/api/workingHoursService";
import { WorkingHoursResponse } from "@/performance-management/types/workingHoursTypes";
import { t } from "i18next";
interface WorkingDaysTableProps {
positionServiceId: string;
onEdit?: (workingDay: WorkingHoursResponse) => void;
onDelete?: (id: string) => void;
refreshTrigger?: number;
}
export const WorkingDaysTable = ({
positionServiceId,
onEdit,
onDelete,
refreshTrigger,
}: WorkingDaysTableProps) => {
const [workingDays, setWorkingDays] = useState<WorkingHoursResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchWorkingDays = async () => {
if (!positionServiceId) {
setWorkingDays([]);
return;
}
setIsLoading(true);
try {
const response = await getWorkingHoursByPositionServiceId(
positionServiceId
);
const data = response.data;
if (data && Array.isArray(data.items)) {
setWorkingDays(data.items);
} else if (Array.isArray(data)) {
setWorkingDays(data);
} else {
setWorkingDays([]);
}
} catch (error) {
console.error("Failed to fetch working days", error);
setWorkingDays([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchWorkingDays();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [positionServiceId, refreshTrigger]);
if (isLoading) {
return (
<div className="space-y-2 p-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
if (workingDays.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
{t("noWorkingDaysFound", "No working days configured for this service")}
</div>
);
}
return (
<div className="border rounded-md bg-white overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("weekDay", "Week Day")}</TableHead>
<TableHead>{t("slug", "Slug")}</TableHead>
<TableHead>{t("startTime", "Start Time")}</TableHead>
<TableHead>{t("endTime", "End Time")}</TableHead>
<TableHead className="text-right">
{t("actions", "Actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workingDays.map((day) => (
<TableRow key={day.id}>
<TableCell className="font-medium capitalize">
{day.weekDay}
</TableCell>
<TableCell>{day.slug}</TableCell>
<TableCell>{day.startTime}</TableCell>
<TableCell>{day.endTime}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{onEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(day)}
>
<Edit className="h-4 w-4" />
</Button>
)}
{onDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Delete Working Hours
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this working hours
configuration? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(day.id)}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,199 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { EditIcon, TrashIcon, PlusIcon } from "lucide-react";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { WorkingHoursResponse } from "../../types/workingHoursTypes";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { ColumnDef } from "@tanstack/react-table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Button } from "@/shared/common/ui/button";
import { Skeleton } from "@/shared/common/ui/skeleton";
import {
useWorkingHoursList,
useWorkingHoursMutations,
} from "@/performance-management/hooks/useWorkingHoursHook";
import { CreateWorkingHoursModal } from "./CreateWorkingHoursModal";
const WorkingHoursTable = () => {
const [pageIndex, setPageIndex] = useState(0);
const [selectedHours, setSelectedHours] =
useState<WorkingHoursResponse | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { data, isLoading, isError, refetch } = useWorkingHoursList();
const { delete: deleteMutation } = useWorkingHoursMutations();
const handleDelete = async () => {
if (!selectedHours) return;
deleteMutation.mutate(selectedHours.id, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
setSelectedHours(null);
refetch();
},
});
};
const workingHours = data?.data?.items || data?.data || [];
const columns: ColumnDef<WorkingHoursResponse>[] = [
{
accessorKey: "slug",
header: "Slug",
size: 150,
},
{
accessorKey: "weekDay",
header: "Week Day",
cell: ({ row }) => (
<span className="capitalize">{row.original.weekDay}</span>
),
},
{
accessorKey: "startTime",
header: "Start Time",
},
{
accessorKey: "endTime",
header: "End Time",
},
{
accessorKey: "positionServiceId",
header: "Position Service ID",
cell: ({ row }) => (
<div
className="max-w-[200px] truncate"
title={row.original.positionServiceId}
>
{row.original.positionServiceId}
</div>
),
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedHours(row.original);
setIsDeleteDialogOpen(true);
}}
disabled={deleteMutation.isPending}
>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
},
];
const extraToolbar = (
<div className="flex gap-2">
<Button
onClick={() => setIsCreateModalOpen(true)}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Working Hours
</Button>
</div>
);
if (isLoading)
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
if (isError)
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
Failed to load working hours
</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
);
return (
<div className="p-6">
<AdvancedTable<WorkingHoursResponse, unknown>
columns={columns}
data={workingHours}
tableName="Working Hours Management"
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={workingHours.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
<CreateWorkingHoursModal
open={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSuccess={refetch}
/>
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Deletion</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete working hours for "
{selectedHours?.weekDay}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
onClick={handleDelete}
>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default WorkingHoursTable;

View File

@@ -0,0 +1,67 @@
import { useQuery } from "@tanstack/react-query";
import {
getEmployeePlanByPlanId,
getEmployeeSubPlanByPlanId,
} from "../services/api/employeePlanService";
interface CombinedEmployeePlanResponse {
items: any[];
total: number;
}
export const useCombinedEmployeePlanByPlanId = (planId: string | undefined) => {
// Main query that intelligently fetches data
const query = useQuery<CombinedEmployeePlanResponse>({
queryKey: ["combined-employee-plan", planId],
queryFn: async () => {
if (!planId) return { items: [], total: 0 };
try {
// First, try to get data from the sub endpoint (this has actual plan data)
const subResponse = await getEmployeeSubPlanByPlanId(planId);
if (subResponse.data?.items && subResponse.data.items.length > 0) {
return {
items: subResponse.data.items,
total: subResponse.data.total || subResponse.data.items.length,
};
}
// If sub endpoint returns empty, try the initial endpoint
const initialResponse = await getEmployeePlanByPlanId(planId);
if (initialResponse.data?.items) {
// Transform initial data to match the expected format
const transformedItems = initialResponse.data.items.map(
(item: any) => ({
...item,
weeks: {}, // Initialize empty weeks object
no_of_employee_plans: "0", // No plans exist yet
})
);
return {
items: transformedItems,
total: transformedItems.length,
};
}
// Both endpoints returned empty
return { items: [], total: 0 };
} catch (error) {
console.error("Error fetching employee plans:", error);
// Return empty but don't throw - we'll show empty state in UI
return { items: [], total: 0 };
}
},
enabled: !!planId,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
};

View File

@@ -0,0 +1,182 @@
// hooks/useEmployeePlanMutations.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { toast } from "sonner";
import { useAuth } from "@/shared/context/AuthContext";
import {
createEmployeePlan,
getEmployeePlan,
getEmployeePlans,
updateEmployeePlan,
deleteEmployeePlan,
getPlansByUnit,
getPositionsByUnit,
fetchImmediateChild,
getServices,
getParentEmployeePlanDetail,
type CreateEmployeePlanDto,
getEmployeePlanByPlanId,
getEmployeeSubPlanByPlanId,
getMyEmployeePlan,
planParam,
} from "../services/api/employeePlanService";
// Query Keys
export const EMPLOYEE_PLAN_KEYS = {
all: ["employee-plans"] as const,
list: (planId: string) => ["employee-plans", "list", planId] as const,
detail: (id: string) => ["employee-plans", id] as const,
plansByUnit: (unitId: string) => ["employee-plans", "plans", unitId] as const,
positionsByUnit: (unitId: string) =>
["employee-plans", "positions", unitId] as const,
employeePositions: () => ["employee-plans", "employee-positions"] as const,
services: ["employee-plans", "services"] as const,
parentDetail: (id: string) => ["employee-plans", "parent", id] as const,
planId: (planId: string) => ["employee-plans", "plan", planId] as const,
subPlanId: (subPlanId: string) =>
["sub-employee-plans", "plan", subPlanId] as const,
};
// GET SINGLE Employee Plan
export function useEmployeePlan(id: string) {
return useQuery({
queryKey: EMPLOYEE_PLAN_KEYS.detail(id),
queryFn: () => getEmployeePlan(id),
enabled: !!id,
});
}
// GET Employee Plans LIST by planId
export function useEmployeePlanList(planId: string) {
return useQuery({
queryKey: EMPLOYEE_PLAN_KEYS.list(planId),
queryFn: () => getEmployeePlans(planId),
enabled: !!planId,
});
}
// GET Plans by Unit
export function usePlansByUnit(unitId: string) {
return useQuery({
queryKey: EMPLOYEE_PLAN_KEYS.plansByUnit(unitId),
queryFn: () => getPlansByUnit(unitId),
enabled: !!unitId,
});
}
// GET Positions by Unit
export function usePositionsByUnit(unitId: string) {
return useQuery({
queryKey: EMPLOYEE_PLAN_KEYS.positionsByUnit(unitId),
queryFn: () => getPositionsByUnit(unitId),
enabled: !!unitId,
});
}
// GET Employee Positions by Position ID
export function useEmployeePositions() {
const { selectedPositionId } = useAuth();
return useQuery({
queryKey: [...EMPLOYEE_PLAN_KEYS.employeePositions(), selectedPositionId],
queryFn: () => fetchImmediateChild(),
enabled: true,
});
}
// GET Services
export function useServices() {
return useQuery({
queryKey: EMPLOYEE_PLAN_KEYS.services,
queryFn: () => getServices(),
});
}
// GET MY Employee Plans
export function useMineEmployeePlan(params: planParam) {
const { selectedPositionId } = useAuth();
return useQuery({
queryKey: ["my-employee-plans", selectedPositionId, params],
queryFn: () => getMyEmployeePlan(params),
});
}
// GET Employee Plans by Plan ID
export function useEmployeePlanByPlanId(planId: string) {
const { selectedPositionId } = useAuth();
return useQuery({
queryKey: [...EMPLOYEE_PLAN_KEYS.planId(planId), selectedPositionId],
queryFn: () => getEmployeePlanByPlanId(planId),
enabled: !!planId,
});
}
// GET Sub Employee Plans by Plan ID
export function useSubEmployeePlanByPlanId(planId: string) {
const { selectedPositionId } = useAuth();
return useQuery({
queryKey: [...EMPLOYEE_PLAN_KEYS.subPlanId(planId), selectedPositionId],
queryFn: () => getEmployeeSubPlanByPlanId(planId),
enabled: !!planId,
});
}
// GET Parent Employee Plan Detail
export function useParentEmployeePlanDetail(id: string) {
return useQuery({
queryKey: EMPLOYEE_PLAN_KEYS.parentDetail(id),
queryFn: () => getParentEmployeePlanDetail(id),
enabled: !!id,
});
}
// Extended DTO interface to include all fields from your form
export interface ExtendedEmployeePlanDto extends CreateEmployeePlanDto {
id?: string;
}
// UNIFIED HOOK - Returns all Employee Plan operations in one object
export function useEmployeePlanMutations() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const createMutation = useMutation({
mutationFn: (data: ExtendedEmployeePlanDto) => {
const { id, ...createData } = data;
return createEmployeePlan(createData);
},
onError: (exception) => {
handleError(exception);
},
});
const updateMutation = useMutation({
mutationFn: (data: ExtendedEmployeePlanDto) => {
if (!data.id) {
throw new Error("Employee plan ID is required for update");
}
const { id, ...updateData } = data;
return updateEmployeePlan(id, updateData);
},
onError: (exception) => {
handleError(exception);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteEmployeePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: EMPLOYEE_PLAN_KEYS.all });
toast.success("Employee plan deleted successfully");
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: EMPLOYEE_PLAN_KEYS.all });
handleError(exception);
},
});
return {
create: createMutation,
update: updateMutation,
delete: deleteMutation,
};
}

View File

@@ -0,0 +1,177 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
metabaseService,
MetabaseDashboard,
GetIframeUrlRequest,
} from "../services/api/metabaseService";
export const useMetabaseDashboard = () => {
const { i18n } = useTranslation();
const [dashboards, setDashboards] = useState<MetabaseDashboard[]>([]);
const [selectedDashboard, setSelectedDashboard] =
useState<MetabaseDashboard | null>(null);
const [iframeUrl, setIframeUrl] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentRequestRef = useRef<string | null>(null);
const fetchDashboards = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await metabaseService.getPermittedDashboards(
i18n.language === "am" ? "am" : "en",
);
setDashboards(response.items);
// Auto-select the first dashboard if available
if (response.items.length > 0 && !selectedDashboard) {
setSelectedDashboard(response.items[0]);
}
} catch (err) {
// Handle different error types
const error = err as { response?: { status?: number } };
if (error?.response?.status === 403) {
setError("You don't have permission to access any dashboards");
} else if (error?.response?.status === 401) {
setError("Please log in to access dashboards");
} else {
setError("Failed to load dashboards");
}
console.error("Error fetching permitted dashboards:", err);
} finally {
setLoading(false);
}
}, [selectedDashboard, i18n.language]);
const getIframeUrl = useCallback(
async (
dashboardId: string,
params: GetIframeUrlRequest = { params: {} },
) => {
// Prevent multiple simultaneous requests for the same dashboard
if (currentRequestRef.current === dashboardId) {
return null;
}
try {
currentRequestRef.current = dashboardId;
setLoading(true);
setError(null);
const response = await metabaseService.getIframeUrl(
dashboardId,
params,
);
const secureUrl = response.iframeUrl.replace(/^http:\/\//i, "https://");
// Only update if this is still the current request
if (currentRequestRef.current === dashboardId) {
setIframeUrl(secureUrl);
}
return secureUrl;
} catch (err) {
if (currentRequestRef.current === dashboardId) {
// Handle permission errors specifically
const error = err as { response?: { status?: number } };
if (error?.response?.status === 403) {
setError("You don't have permission to access this dashboard");
} else {
setError("Failed to load dashboard");
}
console.error("Error getting iframe URL:", err);
}
return null;
} finally {
if (currentRequestRef.current === dashboardId) {
setLoading(false);
currentRequestRef.current = null;
}
}
},
[],
);
const getFilteredDashboardUrl = useCallback(
async (dashboardId: string, planYearId: string) => {
// Prevent multiple simultaneous requests for the same dashboard
const requestKey = `${dashboardId}-${planYearId}`;
if (currentRequestRef.current === requestKey) {
return null;
}
try {
currentRequestRef.current = requestKey;
setLoading(true);
setError(null);
const response = await metabaseService.getFilteredDashboardUrl(
dashboardId,
planYearId,
);
const secureUrl = response.iframeUrl.replace(/^http:\/\//i, "https://");
// Only update if this is still the current request
if (currentRequestRef.current === requestKey) {
setIframeUrl(secureUrl);
}
return secureUrl;
} catch (err) {
if (currentRequestRef.current === requestKey) {
// Handle permission errors specifically
const error = err as { response?: { status?: number } };
if (error?.response?.status === 403) {
setError("You don't have permission to access this dashboard");
} else {
setError("Failed to load filtered dashboard");
}
console.error("Error getting filtered dashboard URL:", err);
}
return null;
} finally {
if (currentRequestRef.current === requestKey) {
setLoading(false);
currentRequestRef.current = null;
}
}
},
[],
);
const selectDashboard = useCallback((dashboard: MetabaseDashboard) => {
setSelectedDashboard(dashboard);
setIframeUrl(""); // Clear previous iframe URL
// The useEffect will handle calling getIframeUrl
}, []);
const getDashboardByNumber = useCallback(
(dashboardNumber: number) => {
return dashboards.find(
(d) => d.metabaseDashboardNumber === dashboardNumber,
);
},
[dashboards],
);
useEffect(() => {
fetchDashboards();
}, [fetchDashboards]);
useEffect(() => {
if (selectedDashboard) {
getIframeUrl(selectedDashboard.id);
}
}, [selectedDashboard, getIframeUrl]);
return {
dashboards,
selectedDashboard,
iframeUrl,
loading,
error,
fetchDashboards,
getIframeUrl,
getFilteredDashboardUrl,
selectDashboard,
getDashboardByNumber,
};
};

View File

@@ -0,0 +1,184 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError, AxiosResponse } from "axios";
import {
getPlanYears,
getPlanYear,
createPlanYear,
updatePlanYear,
deletePlanYear,
PlanYearParams,
} from "../services/api/planYearService";
import { CreatePlanYearDto, PlanYear } from "../types/planYearTypes";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
// Query keys
const PLAN_YEAR_KEYS = {
all: ["planYears"] as const,
lists: () => [...PLAN_YEAR_KEYS.all, "list"] as const,
list: (unitId: string, params?: PlanYearParams) =>
[...PLAN_YEAR_KEYS.lists(), unitId, params] as const,
details: () => [...PLAN_YEAR_KEYS.all, "detail"] as const,
detail: (id: string) => [...PLAN_YEAR_KEYS.details(), id] as const,
active: (unitId: string) =>
[...PLAN_YEAR_KEYS.all, "active", unitId] as const,
};
// Fetch all plan years
export const usePlanYears = (unitId: string, params?: PlanYearParams) => {
return useQuery({
queryKey: PLAN_YEAR_KEYS.list(unitId, params),
queryFn: () => getPlanYears(unitId, params),
enabled: !!unitId,
staleTime: 300_000,
refetchOnWindowFocus: false,
});
};
// Fetch plan year by ID or year number
export const usePlanYearById = (id: string) => {
return useQuery({
queryKey: PLAN_YEAR_KEYS.list(id),
queryFn: () => getPlanYear(id),
enabled: !!id,
staleTime: 300_000,
refetchOnWindowFocus: false,
});
};
// Fetch plan year by year number (e.g., "2024")
export const usePlanYearByNumber = (unitId: string, year: string) => {
return useQuery({
queryKey: [...PLAN_YEAR_KEYS.all, "byYear", unitId, year],
queryFn: async () => {
const response = await getPlanYears(unitId, { take: 100 });
const planYear = response.data?.items?.find(
(py: any) => py.year.toString() === year
);
if (!planYear) {
throw new Error(`Plan year ${year} not found`);
}
return { data: planYear };
},
enabled: !!unitId && !!year && /^\d{4}$/.test(year),
staleTime: 300_000,
refetchOnWindowFocus: false,
});
};
// Fetch single plan year
export const usePlanYear = (id: string) => {
return useQuery({
queryKey: PLAN_YEAR_KEYS.detail(id),
queryFn: () => getPlanYear(id),
enabled: !!id,
staleTime: 300_000,
refetchOnWindowFocus: false,
});
};
// Hook to resolve year parameter (supports both year number and UUID)
export const useResolvedPlanYear = (
yearParam: string,
unitId: string
): {
data: any;
isLoading: boolean;
error: any;
yearId: string | null;
} => {
const isYearNumber = /^\d{4}$/.test(yearParam);
const byNumber = usePlanYearByNumber(
unitId,
isYearNumber ? yearParam : ""
);
const byId = usePlanYearById(!isYearNumber ? yearParam : "");
if (isYearNumber) {
return {
data: byNumber.data,
isLoading: byNumber.isLoading,
error: byNumber.error,
yearId: byNumber.data?.data?.id || null,
};
}
return {
data: byId.data,
isLoading: byId.isLoading,
error: byId.error,
yearId: yearParam,
};
};
// Mutations
export const usePlanYearMutations = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const create = useMutation<
AxiosResponse,
AxiosError,
CreatePlanYearDto,
unknown
>({
mutationFn: createPlanYear,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PLAN_YEAR_KEYS.all });
toast.success(
t("planYear.createdSuccessfully", "Plan year created successfully")
);
},
onError: (error: AxiosError) => {
handleError(error);
},
});
const update = useMutation<
AxiosResponse,
AxiosError,
CreatePlanYearDto,
unknown
>({
mutationFn: updatePlanYear,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: PLAN_YEAR_KEYS.all });
if (variables.id) {
queryClient.invalidateQueries({
queryKey: PLAN_YEAR_KEYS.detail(variables.id),
});
}
toast.success(
t("planYear.updatedSuccessfully", "Plan year updated successfully")
);
},
onError: (error: AxiosError) => {
handleError(error);
},
});
const deleteMutation = useMutation<
AxiosResponse,
AxiosError,
string,
unknown
>({
mutationFn: deletePlanYear,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PLAN_YEAR_KEYS.all });
toast.success(
t("planYear.deletedSuccessfully", "Plan year deleted successfully")
);
},
onError: (error: AxiosError) => {
handleError(error);
},
});
return {
create,
update,
delete: deleteMutation,
};
};

View File

@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/shared/context/AuthContext";
import {
approvePlan,
createPlan,
CreatePlanDto,
deletePlan,
getMinePlan,
getMySubPlans,
getPlan,
getPlanList,
getSectorList,
getSubPlans,
getTeamSubPlans,
PlanParams,
rejectPlan,
requestApproval,
updatePlan,
} from "../services/api/planService";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { MeDto } from "@/shared/dto/user/meDto";
import { SectorListParam } from "../types/planTypes";
// Query Keys
const Plan_KEYS = {
all: ["plans"] as const,
list: (id: string, params?: PlanParams) =>
["plans", "list", id, params] as const,
detail: (id: string) => ["plans", id] as const,
};
// Helper function to check if user is unit admin
export const checkIsUnitAdmin = (user: MeDto | null): boolean => {
if (!user || !user.roles) return false;
return user.roles.some((role) => role.key === "unit_admin");
};
export const checkIsPlanInitiator = (
user: MeDto | null,
positionId?: string
): boolean => {
if (!user || !user.employee) return false;
if (positionId) {
// Check specific position
return user.employee.some((emp) =>
emp.positions.some(
(pos) =>
pos.employeePositionId === positionId &&
pos.permissions.some((perm) => perm.key === "can:initiatePlan")
)
);
}
// Check all positions (fallback)
return user.employee.some((emp) =>
emp.positions.some((pos) =>
pos.permissions.some((perm) => perm.key === "can:initiatePlan")
)
);
};
// GET SINGLE Plan
export function usePlan(id: string) {
return useQuery({
queryKey: Plan_KEYS.detail(id),
queryFn: () => getPlan(id),
enabled: !!id,
});
}
export function useSectorList(id?: string) {
return useQuery({
queryKey: ["plans-sector", "sector", id],
queryFn: () => getSectorList(id),
enabled: !!id,
});
}
export function useMinePlan(params?: SectorListParam) {
const { selectedPositionId } = useAuth();
return useQuery({
// Include selectedPositionId in the key so switching positions yields a
// distinct cache entry and refetches with the up-to-date header.
queryKey: ["my-plans", selectedPositionId, params],
queryFn: () => getMinePlan(params),
});
}
// GET Plan LIST with optional params filtering
export function usePlanList(unitId: string, params?: PlanParams) {
return useQuery({
queryKey: Plan_KEYS.list(unitId, params),
queryFn: () => getPlanList(unitId, params),
enabled: !!unitId,
});
}
export function useSubPlans(id: string) {
return useQuery({
queryKey: ["plans", "sub-plans", id],
queryFn: () => getSubPlans(id),
enabled: !!id,
});
}
export function useMySubPlans(id: string) {
return useQuery({
queryKey: ["plans", "my-sub-plans", id],
queryFn: () => getMySubPlans(id),
enabled: !!id,
});
}
export function useTeamSubPlans(id: string) {
return useQuery({
queryKey: ["plans", "team-sub-plans", id],
queryFn: () => getTeamSubPlans(id),
enabled: !!id,
});
}
//UNIFIED HOOK - Returns all Plan operations in one object
export function usePlanMutations() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const createMutation = useMutation({
mutationFn: (data: CreatePlanDto) => createPlan(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
toast("Plan created successfully");
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
handleError(exception);
},
});
const updateMutation = useMutation({
mutationFn: (data: CreatePlanDto) => updatePlan(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
handleError(exception);
},
});
const requestApprovalMutation = useMutation({
mutationFn: (id: string) => requestApproval(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
toast("Plan Approval Requested successfully");
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
handleError(exception);
},
});
const approveMutation = useMutation({
mutationFn: (id: string) => approvePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
toast("Plan Approved successfully");
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
handleError(exception);
},
});
const rejectMutation = useMutation({
mutationFn: (id: string) => rejectPlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
toast("Plan Rejected successfully");
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
handleError(exception);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
toast("Plan deleted successfully");
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: Plan_KEYS.all });
handleError(exception);
},
});
return {
create: createMutation,
update: updateMutation,
delete: deleteMutation,
requestApproval: requestApprovalMutation,
approve: approveMutation,
reject: rejectMutation,
};
}

View File

@@ -0,0 +1,77 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
serviceCategoryService,
CreateServiceCategoryRequest,
} from "../services/api/serviceCategoryService";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
// Query Keys
const SERVICE_CATEGORY_KEYS = {
all: ["service-categories"] as const,
list: (organizationId: string) =>
["service-categories", "list", organizationId] as const,
};
// GET SERVICE CATEGORY LIST
export function useServiceCategoryList(organizationId: string) {
return useQuery({
queryKey: SERVICE_CATEGORY_KEYS.list(organizationId),
queryFn: () => serviceCategoryService.getList(organizationId),
enabled: !!organizationId,
});
}
// 🎯 UNIFIED HOOK - Returns all service category operations in one object
export function useServiceCategoryMutations() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const createMutation = useMutation({
mutationFn: (data: CreateServiceCategoryRequest) =>
serviceCategoryService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SERVICE_CATEGORY_KEYS.all });
toast("Service Category created successfully");
},
onError: (exception) => {
handleError(exception);
},
});
const updateMutation = useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: CreateServiceCategoryRequest;
}) => serviceCategoryService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SERVICE_CATEGORY_KEYS.all });
toast("Service Category updated successfully");
},
onError: (exception) => {
handleError(exception);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => serviceCategoryService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SERVICE_CATEGORY_KEYS.all });
toast("Service Category deleted successfully");
},
onError: (exception) => {
handleError(exception);
},
});
return {
create: createMutation,
update: updateMutation,
delete: deleteMutation,
};
}

View File

@@ -0,0 +1,109 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createService,
updateService,
deleteService,
getService,
getServiceList,
CreateServiceDto,
} from "../services/api/performanceService";
import { useAuth } from "@/shared/context/AuthContext";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useNavigate, useParams } from "react-router-dom";
// Query Keys
const SERVICE_KEYS = {
all: ["services"] as const,
list: (id: string) => ["services", "list", id] as const,
detail: (id: string) => ["services", id] as const,
};
// GET SINGLE SERVICE
export function useService(id: string) {
return useQuery({
queryKey: SERVICE_KEYS.detail(id),
queryFn: () => getService(id),
enabled: !!id,
});
}
// GET SERVICE LIST
export function useServiceList(
organizationId: string,
serviceCategoryId?: string,
parentServiceId?: string
) {
// react-query
const queryKey = [
...SERVICE_KEYS.list(organizationId as string),
...(serviceCategoryId ? [serviceCategoryId] : []),
...(parentServiceId ? [parentServiceId] : []),
];
return useQuery({
queryKey,
queryFn: () =>
getServiceList(
organizationId as string,
serviceCategoryId,
parentServiceId
),
enabled: !!organizationId, // only fetch when organizationId exists
});
}
// 🎯 UNIFIED HOOK - Returns all service operations in one object
export function useServiceMutations() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const navigate = useNavigate();
const createMutation = useMutation({
mutationFn: (data: CreateServiceDto) => createService(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SERVICE_KEYS.all });
toast("Service created successfully");
navigate(`/performance-management/services`);
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: SERVICE_KEYS.all });
handleError(exception);
},
});
const updateMutation = useMutation({
mutationFn: (data: CreateServiceDto) => updateService(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SERVICE_KEYS.all });
toast("Service updated successfully");
navigate(`/performance-management/services`);
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: SERVICE_KEYS.all });
handleError(exception);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteService(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SERVICE_KEYS.all });
toast("Service deleted successfully");
navigate(`/performance-management/services`);
},
onError: (exception) => {
queryClient.invalidateQueries({ queryKey: SERVICE_KEYS.all });
handleError(exception);
},
});
return {
create: createMutation,
update: updateMutation,
delete: deleteMutation,
};
}

View File

@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPlanBulkTeamMember,
CreatePlanBulkTeamMemberDto,
createPlanTeam,
CreatePlanTeamDto,
createPlanTeamMember,
CreatePlanTeamMemberDto,
deletePlanTeam,
deletePlanTeamMember,
getPlanTeamList,
getPlanTeamMemberList,
updatePlanTeam,
updatePlanTeamMember,
} from "../services/api/planTeamServices";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { getEnvUrl } from "@/shared/config/env";
import { id } from "date-fns/locale";
// Fetch teams
export const usePlanTeamList = (id: string, options?: { enabled?: boolean }) =>
useQuery({
queryKey: ["plan-team-list", id],
queryFn: () => getPlanTeamList(id),
enabled: options?.enabled !== false && !!id, // Only fetch when enabled and id exists
});
export const usePlanTeamMemberList = (
id: string,
options?: { enabled?: boolean },
) =>
useQuery({
queryKey: ["plan-team-member-list", id],
queryFn: () => getPlanTeamMemberList(id),
enabled: options?.enabled !== false && !!id, // Only fetch when enabled and id exists
});
export const usePlanTeamMutation = () => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const queryClient = useQueryClient();
const createPlanTeamMutation = useMutation({
mutationFn: ({ data }: { data: CreatePlanTeamDto }) => createPlanTeam(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["plan-team-list"] });
},
onError: (error) => {
handleError(error);
},
});
const updatePlanTeamMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreatePlanTeamDto }) =>
updatePlanTeam(id, data),
onSuccess: () => {},
onError: (error) => {
handleError(error);
},
});
const deletePlanTeamMutation = useMutation({
mutationFn: ({ id }: { id: string }) => deletePlanTeam(id),
onSuccess: () => {
toast.success("Plan team deleted successfully");
},
onError: (error) => {
handleError(error);
},
});
const createPlanTeamMemberMutation = useMutation({
mutationFn: ({ data }: { data: CreatePlanTeamMemberDto }) =>
createPlanTeamMember(data),
onSuccess: () => {
toast.success("Plan team member created successfully");
},
onError: (error) => {
handleError(error);
},
});
const createPlanBulkTeamMemberMutation = useMutation({
mutationFn: ({ data }: { data: CreatePlanBulkTeamMemberDto }) =>
createPlanBulkTeamMember(data),
onSuccess: () => {
toast.success("Plan team member created successfully");
},
onError: (error) => {
handleError(error);
},
});
const updatePlanTeamMemberMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreatePlanTeamMemberDto }) =>
updatePlanTeamMember(id, data),
onSuccess: () => {
toast.success("Plan team member updated successfully");
},
onError: (error) => {
handleError(error);
},
});
const deletePlanTeamMemberMutation = useMutation({
mutationFn: deletePlanTeamMember,
onSuccess: () => {
toast.success("Plan team member deleted successfully");
},
onError: (error) => {
handleError(error);
},
});
return {
createPlanTeam: createPlanTeamMutation.mutate,
isLoading: createPlanTeamMutation.isPending,
isError: createPlanTeamMutation.isError,
error: createPlanTeamMutation.error,
updatePlanTeam: updatePlanTeamMutation.mutate,
deletePlanTeam: deletePlanTeamMutation.mutate,
createPlanTeamMember: createPlanTeamMemberMutation.mutate,
updatePlanTeamMember: updatePlanTeamMemberMutation.mutate,
deletePlanTeamMember: deletePlanTeamMemberMutation.mutate,
isPlanTeamLoading: updatePlanTeamMutation.isPending,
isPlanTeamError: updatePlanTeamMutation.isError,
planTeamError: updatePlanTeamMutation.error,
isPlanTeamMemberLoading: deletePlanTeamMutation.isPending,
isPlanTeamMemberError: deletePlanTeamMutation.isError,
planTeamMemberError: deletePlanTeamMutation.error,
isPlanTeamMemberCreateLoading: createPlanTeamMemberMutation.isPending,
isPlanTeamMemberCreateError: createPlanTeamMemberMutation.isError,
planTeamMemberCreateError: createPlanTeamMemberMutation.error,
isPlanTeamMemberUpdateLoading: updatePlanTeamMemberMutation.isPending,
isPlanTeamMemberUpdateError: updatePlanTeamMemberMutation.isError,
planTeamMemberUpdateError: updatePlanTeamMemberMutation.error,
isPlanTeamMemberDeleteLoading: deletePlanTeamMemberMutation.isPending,
isPlanTeamMemberDeleteError: deletePlanTeamMemberMutation.isError,
planTeamMemberDeleteError: deletePlanTeamMemberMutation.error,
createPlanBulkTeamMember: createPlanBulkTeamMemberMutation.mutate,
isPlanBulkTeamMemberCreateLoading:
createPlanBulkTeamMemberMutation.isPending,
};
};
// Fetch users for team member selection
export const useUsers = () =>
useQuery({
queryKey: ["users"],
queryFn: async () => {
const apiBaseUrl = getEnvUrl("VITE_API_URL");
const response = await fetch(`${apiBaseUrl}/users/active`);
return response.json();
},
});

View File

@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
createWorkingHours,
getWorkingHoursList,
getWorkingHoursByPositionServiceId,
updateWorkingHours,
deleteWorkingHours,
} from "../services/api/workingHoursService";
import { CreateWorkingHoursDto } from "../types/workingHoursTypes";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
export const useWorkingHoursList = () => {
return useQuery({
queryKey: ["workingHours"],
queryFn: getWorkingHoursList,
});
};
export const useWorkingHoursByPositionService = (positionServiceId: string) => {
return useQuery({
queryKey: ["workingHours", positionServiceId],
queryFn: () => getWorkingHoursByPositionServiceId(positionServiceId),
enabled: !!positionServiceId,
});
};
export const useWorkingHoursMutations = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const create = useMutation({
mutationFn: (data: CreateWorkingHoursDto) => createWorkingHours(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workingHours"] });
toast.success(
t("workingHours.createdSuccessfully", "Working hours created successfully")
);
},
onError: (error: any) => {
handleError(error);
},
});
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateWorkingHoursDto }) =>
updateWorkingHours(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workingHours"] });
toast.success(
t("workingHours.updatedSuccessfully", "Working hours updated successfully")
);
},
onError: (error: any) => {
handleError(error);
},
});
const deleteHours = useMutation({
mutationFn: (id: string) => deleteWorkingHours(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["workingHours"] });
toast.success(
t("workingHours.deletedSuccessfully", "Working hours deleted successfully")
);
},
onError: (error: any) => {
handleError(error);
},
});
return { create, update, delete: deleteHours };
};

View File

@@ -0,0 +1,333 @@
import { NavLink, useParams } from "react-router-dom";
import { useAuth } from "@/shared/context/AuthContext";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
// Icons
import {
ChevronDown,
ChevronRight,
Home,
TableProperties,
Calendar,
} from "lucide-react";
import { MenuItem as BaseMenuItem } from "@/layout/AppMenuTabs";
import { FaTasks } from "react-icons/fa";
import { getSelectedPositionType } from "@/performance-management/utils/positionTypeUtils";
import { EPositionTypeKey } from "@/shared/dto/user/meDto";
import { checkIsPlanInitiator } from "@/performance-management/hooks/usePlans";
// Extended MenuItem interface with position type support
interface MenuItem extends BaseMenuItem {
positionTypes?: EPositionTypeKey[];
}
interface PerformanceMenuTablsProps {
collapsed?: boolean;
toggleSidebar?: () => void;
}
export const PerformanceMenuTabls = ({
collapsed = false,
}: PerformanceMenuTablsProps) => {
const { user, selectedPositionId } = useAuth();
const userRoles = user?.roles.map((role) => role.key) || [];
const userPositionType = getSelectedPositionType(user, selectedPositionId);
const isPlanInitiator = checkIsPlanInitiator(
user,
selectedPositionId || undefined,
);
// Debug: log user roles and position type
console.log("User roles:", userRoles);
console.log("User position type:", userPositionType);
console.log("Has empty role:", userRoles.includes(""));
const { t } = useTranslation();
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const toggleDropdown = (label: string) => {
setOpenDropdown((prev) => (prev === label ? null : label));
};
const { yearId } = useParams<{ yearId: string }>();
// Year-independent menu items (always visible)
const yearIndependentItems: MenuItem[] = [
{
label: "Plan Years",
href: "/performance-management/plan-years",
icon: <Calendar className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", ""],
isPrimary: true,
},
{
label: "Service Categories",
href: "/performance-management/service-categories",
icon: <TableProperties className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", "user"],
isPrimary: true,
},
{
label: "Services",
href: `/performance-management/services`,
icon: <TableProperties className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", "user"],
isPrimary: true,
},
{
label: "Service Assignment",
href: `/performance-management/service-assignment`,
icon: <TableProperties className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", "user"],
isPrimary: true,
},
{
label: "Active Call Management",
href: `/performance-management/active-call-management`,
icon: <TableProperties className="h-4 w-4" />,
roles: [""],
positionTypes: [EPositionTypeKey.EMPLOYEE],
isPrimary: true,
},
];
// Year-dependent menu items (only visible when yearId exists)
const yearDependentItems: MenuItem[] = [
{
label: "dashboard",
href: `/performance-management/${yearId}/dashboard`,
icon: <Home className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", ""],
isPrimary: true,
},
{
label: "Plans",
href: `/performance-management/${yearId}/${
isPlanInitiator ? "plans" : "my-plans"
}`,
icon: <TableProperties className="h-4 w-4" />,
roles: [""],
positionTypes: [
EPositionTypeKey.DIRECTOR,
EPositionTypeKey.DEPUTY,
EPositionTypeKey.OFFICE_HEAD,
EPositionTypeKey.BUDGET_PLAN,
],
isPrimary: true,
},
{
label: "Employee Plans",
href: `/performance-management/${yearId}/employee-plans`,
icon: <FaTasks className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", ""],
// Employee Plans is relevant for anyone who can be assigned monthly
// tasks, which is effectively every employee position. Leaving the
// positionTypes filter off avoids hiding the link when the backend
// returns a positionType.key that doesn't match the enum (e.g. an
// unexpected key would otherwise hide the item entirely).
isPrimary: true,
},
{
label: "Team Member Management",
href: `/performance-management/${yearId}/team-member-management`,
icon: <FaTasks className="h-4 w-4" />,
roles: ["super_admin", "unit_admin", ""],
isPrimary: true,
},
];
// Filter items based on user roles and position types
const filterByRoles = (items: MenuItem[]) => {
return items.filter((item) => {
// Check role requirements
let hasRole = true;
if (item.roles && item.roles.length > 0) {
hasRole = item.roles.some((role) => userRoles.includes(role));
// Special case: If item allows "" (empty string role), it should ALSO match users with NO roles ([])
if (item.roles.includes("") && userRoles.length === 0) {
hasRole = true;
}
}
// Check position type requirements
let hasPositionType = true;
if (item.positionTypes && item.positionTypes.length > 0) {
hasPositionType = userPositionType
? item.positionTypes.includes(userPositionType)
: false;
}
// User must satisfy both role AND position type requirements (if they exist)
return hasRole && hasPositionType;
});
};
// Combine and filter menu items based on whether yearId exists and user roles
const filteredMenu = yearId
? filterByRoles([...yearIndependentItems, ...yearDependentItems])
: filterByRoles(yearIndependentItems);
const primaryMenu = filteredMenu.filter((i) => i.isPrimary);
const secondaryMenu = filteredMenu.filter((i) => !i.isPrimary);
const renderChildren = (children: MenuItem[]) => {
return (
<div className="ml-8 mt-1 space-y-1">
{children.map((child) => (
<NavLink
key={child.href}
to={child.href!}
className={({ isActive }) =>
`block px-3 py-2 text-sm rounded-md transition-colors ${
isActive
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
}`
}
>
{t(`organization.${child.label}`)}
</NavLink>
))}
</div>
);
};
// ---- MAIN RETURN ----
return (
<>
{/* --------------------- MOBILE TABS --------------------- */}
<div className="md:hidden fixed top-16 left-0 right-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-800 pt-5 pb-3 px-4 space-y-3">
{/* Primary row */}
<div className="flex items-center overflow-x-auto scrollbar-none">
{primaryMenu.map((item) => (
<NavLink
key={item.href}
to={item.href!}
className={({ isActive }) => {
// Custom active check: for Plans, match /plans, /plans-table, /my-plans, or /plan/{id}
let isItemActive = isActive;
if (item.label === "Plans") {
const currentPath = window.location.pathname;
isItemActive =
currentPath.includes("/my-plans") ||
currentPath.includes("/plan/") ||
currentPath.includes("/plans");
}
return `flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium ${
isItemActive
? "text-[#A043F0] border-b-2 border-[#A043F0]"
: "text-slate-700 hover:text-[#A043F0]"
}`;
}}
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`)}
</span>
</NavLink>
))}
</div>
{/* Secondary row */}
{secondaryMenu.length > 0 && (
<div className="flex items-center overflow-x-auto border-t pt-3">
{secondaryMenu.map((item) => (
<NavLink
key={item.label}
to={item.href || "#"}
className="flex items-center gap-1 px-3 py-2.5 mr-2 text-xs font-medium text-slate-700 hover:text-[#A043F0]"
>
{item.icon}
<span className="max-w-[80px] truncate">
{t(`organization.${item.label}`)}
</span>
</NavLink>
))}
</div>
)}
</div>
{/* --------------------- DESKTOP SIDEBAR --------------------- */}
<div className="hidden md:flex flex-col h-full">
<ScrollArea className="flex-1">
<div className="mt-4 space-y-1">
{filteredMenu.map((item) => {
// ---------- PARENT WITH CHILDREN ----------
if (item.children && item.children.length > 0) {
const isOpen = openDropdown === item.label;
return (
<div key={item.label} className="mx-2">
{/* Parent row */}
<button
onClick={() => toggleDropdown(item.label)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left ${
isOpen
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
} ${collapsed ? "justify-center" : ""}`}
>
{item.icon}
{!collapsed && (
<>
<span className="font-medium whitespace-nowrap">
{t(`organization.${item.label}`)}
</span>
<div className="ml-auto">
{isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</>
)}
</button>
{/* Children */}
{!collapsed && isOpen && renderChildren(item.children)}
</div>
);
}
// ---------- REGULAR ITEM ----------
return (
<NavLink
key={item.href}
to={item.href!}
className={({ isActive }) => {
// Custom active check: for Plans, check if path contains /my-plans or /plan/
let isItemActive = isActive;
if (item.label === "Plans") {
const currentPath = window.location.pathname;
isItemActive =
currentPath.includes("/my-plans") ||
currentPath.includes("/plan/");
}
return `flex items-center gap-3 px-3 py-2.5 mx-2 rounded-lg transition-colors ${
isItemActive
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
} ${collapsed ? "justify-center" : ""}`;
}}
>
{item.icon}
{!collapsed && (
<span className="font-medium whitespace-nowrap">
{t(`organization.${item.label}`)}
</span>
)}
</NavLink>
);
})}
</div>
</ScrollArea>
</div>
</>
);
};

View File

@@ -0,0 +1,17 @@
import React from "react";
import { useTranslation } from "react-i18next";
import PlanHierarchy from "@/performance-management/components/PlanHierarchy/PlanHierarchy";
const PlanLayout: React.FC = () => {
const { t } = useTranslation();
return (
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
<h1 className="text-2xl font-semibold text-gray-800 dark:text-gray-200 mb-4">
{t("planTable.planManagement", "Plan Management")}
</h1>
<PlanHierarchy />
</div>
);
};
export default PlanLayout;

View File

@@ -0,0 +1,389 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { EditIcon, TrashIcon, PlusIcon } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { useAuth } from "@/shared/context/AuthContext";
import {
useServiceCategoryList,
useServiceCategoryMutations,
} from "@/performance-management/hooks/useServiceCategory";
import { ServiceCategory } from "@/performance-management/services/api/serviceCategoryService";
import { ServiceCategoryForm } from "@/performance-management/components/ServiceCategory/ServiceCategoryForm";
import { useLocalizedName } from "@/shared/common/localizedName";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { ColumnDef } from "@tanstack/react-table";
import { useToast } from "@/shared/common/ui/use-toast";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/common/ui/tooltip";
import { Skeleton } from "@/shared/common/ui/skeleton";
export const ServiceCategoryPage = () => {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const { user } = useAuth();
const { toast } = useToast();
const { delete: deleteCategory } = useServiceCategoryMutations();
const [pageIndex, setPageIndex] = useState(0);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] =
useState<ServiceCategory | null>(null);
const [selectedCategory, setSelectedCategory] =
useState<ServiceCategory | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const organizationId = user?.employee?.[0]?.organizationId;
const {
data: serviceCategoryList,
isLoading,
isError,
refetch,
} = useServiceCategoryList(organizationId ?? "");
const categories = (serviceCategoryList?.data?.items || []).sort(
(a: ServiceCategory, b: ServiceCategory) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateB - dateA; // Descending order (newest first)
}
);
const handleEdit = (category: ServiceCategory) => {
setEditingCategory(category);
};
const handleDelete = async () => {
if (!selectedCategory) return;
try {
await deleteCategory.mutateAsync(selectedCategory.id);
toast({
title: t("serviceCategory.deleteSuccess", "Deleted"),
description: t(
"serviceCategory.deleteSuccessMessage",
"Service category deleted successfully"
),
});
setIsDeleteDialogOpen(false);
setSelectedCategory(null);
refetch();
} catch {
toast({
title: t("common.error", "Error"),
description: t(
"serviceCategory.deleteError",
"Failed to delete service category"
),
variant: "destructive",
});
}
};
const handleFormSuccess = () => {
setIsCreateDialogOpen(false);
setEditingCategory(null);
refetch();
};
const handleFormCancel = () => {
setIsCreateDialogOpen(false);
setEditingCategory(null);
};
const columns: ColumnDef<ServiceCategory>[] = [
{
accessorKey: "slug",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("serviceCategory.slug", "Slug")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => <div>{row.original.slug}</div>,
size: 150,
enableSorting: true,
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("serviceCategory.name", "Name")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => <div>{localizedName(row.original.name)}</div>,
size: 200,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const nameA = localizedName(rowA.original.name).toLowerCase();
const nameB = localizedName(rowB.original.name).toLowerCase();
return nameA.localeCompare(nameB);
},
},
{
accessorKey: "description",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("serviceCategory.description", "Description")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: ""}
</Button>
);
},
cell: ({ row }) => (
<div
className="max-w-[300px] truncate"
title={localizedName(row.original.description)}
>
{localizedName(row.original.description)}
</div>
),
enableSorting: true,
sortingFn: (rowA, rowB) => {
const descA = localizedName(rowA.original.description).toLowerCase();
const descB = localizedName(rowB.original.description).toLowerCase();
return descA.localeCompare(descB);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold"
>
{t("serviceCategory.createdAt", "Created At")}
{column.getIsSorted() === "asc"
? " ↑"
: column.getIsSorted() === "desc"
? " ↓"
: " ↓"}
</Button>
);
},
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
size: 150,
enableSorting: true,
sortingFn: (rowA, rowB) => {
const dateA = new Date(rowA.original.createdAt).getTime();
const dateB = new Date(rowB.original.createdAt).getTime();
return dateA - dateB;
},
},
{
id: "actions",
header: t("common.actions", "Actions"),
cell: ({ row }) => (
<div className="flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(row.original)}
>
<EditIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.edit", "Edit")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedCategory(row.original);
setIsDeleteDialogOpen(true);
}}
disabled={deleteCategory.isPending}
>
<TrashIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.delete", "Delete")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
),
size: 100,
},
];
const extraToolbar = (
<div className="flex gap-2">
<Button
onClick={() => setIsCreateDialogOpen(true)}
className="bg-gradient-to-r from-purple-500 to-violet-600 text-white"
>
<PlusIcon className="h-4 w-4 mr-2" />
{t("serviceCategory.createNew", "Create Category")}
</Button>
</div>
);
// Loading UI
if (isLoading) {
return (
<div className="p-6 space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
// Error UI
if (isError) {
return (
<div className="p-6 space-y-4">
<p className="text-destructive font-semibold">
{t("serviceCategory.loadError", "Failed to load service categories")}
</p>
<Button onClick={() => refetch()}>{t("common.retry", "Retry")}</Button>
</div>
);
}
return (
<div className="p-6">
<AdvancedTable<ServiceCategory, unknown>
columns={columns}
data={categories}
tableName={t("serviceCategory.title", "Service Categories")}
extraToolbar={extraToolbar}
toolBarPosition="right"
itemCount={categories.length}
pageIndex={pageIndex}
onPageChange={setPageIndex}
nextFunction={() => setPageIndex((p) => p + 1)}
prevFunction={() => setPageIndex((p) => Math.max(0, p - 1))}
refresh={refetch}
/>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader></DialogHeader>
<ServiceCategoryForm
onSuccess={handleFormSuccess}
onCancel={handleFormCancel}
/>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog
open={!!editingCategory}
onOpenChange={() => setEditingCategory(null)}
>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("serviceCategory.editTitle", "Edit Service Category")}
</DialogTitle>
</DialogHeader>
{editingCategory && (
<ServiceCategoryForm
category={editingCategory}
onSuccess={handleFormSuccess}
onCancel={handleFormCancel}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Dialog */}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("serviceCategory.deleteTitle", "Delete Service Category")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"serviceCategory.deleteConfirmation",
'Are you sure you want to delete "{{name}}"? This action cannot be undone.',
{ name: localizedName(selectedCategory?.name) }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteCategory.isPending}
onClick={handleDelete}
>
{deleteCategory.isPending
? t("common.deleting", "Deleting...")
: t("common.delete", "Delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -0,0 +1,111 @@
import { Outlet, useLocation } from "react-router-dom";
import { DynamicBreadcrumb } from "@/shared/components/ui/DynamicBreadcrumb";
import { TopBar } from "@/layout/TopBar";
import { useState } from "react";
import { PerformanceMenuTabls } from "../layout/performanceMenuLayout";
import { Button } from "@/shared/common/ui/button";
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
import { Sheet, SheetContent, SheetTrigger } from "@/shared/common/ui/sheet";
import { PositionSelect } from "@/record-management/components/positionSelection";
export const PerformanceAppLayout = () => {
const { pathname } = useLocation();
const isAuthPage = pathname === "/";
const [sidebarOpen, setSidebarOpen] = useState(true);
const toggleSidebar = () => setSidebarOpen((prev) => !prev);
if (isAuthPage) {
return (
<div className="min-h-screen w-full bg-gray-50">
<Outlet />
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-background text-foreground">
{/* TOP BAR */}
<TopBar />
{/* CONTENT AREA */}
<div className="flex flex-1 pt-16">
{/* SIDEBAR */}
<div
className={`hidden md:flex flex-col h-[calc(100vh-4rem)] fixed left-0 top-16 transition-all duration-300 bg-white dark:bg-gray-900 border-r dark:border-gray-800 z-20 ${
sidebarOpen ? "w-64" : "w-16"
}`}>
{/* Sidebar content */}
<PerformanceMenuTabls
collapsed={!sidebarOpen}
toggleSidebar={toggleSidebar}
/>
{/* Sidebar toggle button */}
<div className="p-3 border-t dark:border-gray-800 mt-auto">
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="w-full flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
{sidebarOpen ? (
<>
<ChevronLeft className="h-4 w-4" />
{!sidebarOpen && (
<span className="sr-only">Expand sidebar</span>
)}
</>
) : (
<>
<ChevronRight className="h-4 w-4" />
{sidebarOpen && (
<span className="sr-only">Collapse sidebar</span>
)}
</>
)}
</Button>
</div>
</div>
{/* MOBILE SIDEBAR TRIGGER */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<Sheet>
<SheetTrigger asChild>
<Button
size="icon"
className="h-12 w-12 rounded-full shadow-lg bg-[#A043F0] hover:bg-[#8a36d0]">
<Menu className="h-6 w-6 text-white" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-0">
<div className="flex flex-col h-full">
<div className="p-4 border-b">
<h2 className="text-lg font-semibold">Menu</h2>
</div>
<div className="flex-1 p-2">
<PerformanceMenuTabls />
</div>
</div>
</SheetContent>
</Sheet>
</div>
{/* MAIN CONTENT */}
<div
className={`flex-1 transition-all duration-300 min-h-[calc(100vh-4rem)] w-full ${
sidebarOpen ? "md:ml-64" : "md:ml-16"
}`}>
<div className="p-4 md:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<DynamicBreadcrumb />
<PositionSelect />
</div>
<div className="w-full">
<Outlet />
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,201 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import axiosInstance from "@/shared/services/axiosInstance";
import performanceInstance from "@/shared/services/performanceInstance";
import { AxiosResponse } from "axios";
export interface CreateEmployeePlanDto {
planId: string;
employeePositionId: string;
serviceId?: string;
parentEmployeePlanId?: string;
expectedQuantity: number;
planType: "number" | "percent" | "boolean";
timeframe: string;
week?: number;
month?: number;
quarter?: number;
}
export interface EmployeeDTO {
createdAt: string;
updatedAt: string;
id: string;
isCurrent: boolean;
status: string;
name: LocalizedName;
organizationId: string;
unitId: string;
userId: string;
employeePositions: EmployeePositionDTO[];
}
export interface LocalizedName {
am: string;
en: string;
}
export interface planParam {
timeframe: string;
skip?: number;
take?: number;
}
export interface EmployeePositionDTO {
createdAt: string;
updatedAt: string;
id: string;
isDelegate: boolean;
isCurrent: boolean;
startDate: string;
amharicStartDate: string;
endDate: string | null;
amharicEndDate: string | null;
unitId: string;
delegatorId: string | null;
employeeId: string;
positionId: string;
delegationLetterId: string | null;
position: PositionDTO;
}
export interface PositionDTO {
createdAt: string;
updatedAt: string;
id: string;
name: LocalizedName;
key: string;
rank: number;
parentPositionId: string | null;
unitId: string;
organizationId: string;
projectId: string | null;
positionTypeId: string;
}
export interface ImmediateChildResponse {
count: number;
items: EmployeeDTO[];
}
export const createEmployeePlan = async (
data: CreateEmployeePlanDto
): Promise<AxiosResponse> => {
return performanceInstance.post("/employee-plans", data, {
headers: withHeaders(),
});
};
export const getEmployeePlans = async (
planId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/employee-plans/list/${planId}`, {
headers: withHeaders(),
});
};
export const getEmployeePlan = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.get(`/employee-plans/${id}`, {
headers: withHeaders(),
});
};
export const updateEmployeePlan = async (
id: string,
data: CreateEmployeePlanDto
): Promise<AxiosResponse> => {
return performanceInstance.put(`/employee-plans/${id}`, data, {
headers: withHeaders(),
});
};
export const deleteEmployeePlan = async (
id: string
): Promise<AxiosResponse> => {
return performanceInstance.delete(`/employee-plans/${id}`, {
headers: withHeaders(),
});
};
export const getPlansByUnit = async (
unitId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/mine`, {
headers: withHeaders(),
params: { unitId },
});
};
export const getPositionsByUnit = async (
unitId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/positions`, {
headers: withHeaders(),
params: { unitId },
});
};
export const getEmployeePositions = async (
positionId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/employee-positions`, {
headers: withHeaders(),
params: { positionId },
});
};
export const getServices = async (): Promise<AxiosResponse> => {
return performanceInstance.get(`/services`, {
headers: withHeaders(),
});
};
export const getParentEmployeePlanDetail = async (
id: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/employee-plans/${id}`, {
headers: withHeaders(),
});
};
export const fetchImmediateChild =
async (): Promise<ImmediateChildResponse> => {
try {
const { data } = await axiosInstance.get(`/employees/immediate-child`, {
headers: withHeaders(),
});
return data;
} catch (error: any) {
throw new Error(
error?.response?.data?.message || "Failed to fetch immediate child"
);
}
};
export const getEmployeePlanByPlanId = async (
planId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(
`/employee-plans/plan/${planId}/employee-plans`,
{
headers: withHeaders(),
}
);
};
export const getEmployeeSubPlanByPlanId = async (
planId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(
`/employee-plans/${planId}/sub-employee-plans`,
{
headers: withHeaders(),
}
);
};
export const getMyEmployeePlan = async (
params?: planParam
): Promise<AxiosResponse> => {
return performanceInstance.get(`/employee-plans/mine`, {
headers: withHeaders(),
params,
});
};

View File

@@ -0,0 +1,147 @@
import axios, { AxiosResponse } from "axios";
import Cookies from "js-cookie";
import { getEnvUrl } from "@/shared/config/env";
export interface MetabaseDashboard {
id: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
metabaseDashboardNumber: number;
permissionId: string;
createdAt: string;
updatedAt: string;
tenantId: string;
params: string[];
}
export interface MetabaseDashboardsResponse {
count: number;
items: MetabaseDashboard[];
}
export interface MetabaseIframeResponse {
iframeUrl: string;
}
export interface CreateDashboardRequest {
name: {
am: string;
en: string;
};
metabaseDashboardNumber: number;
description: {
am: string;
en: string;
};
permissionId: string;
params: Record<string, unknown>[];
}
export interface GetIframeUrlRequest {
params: Record<string, unknown>;
}
// Create a dedicated axios instance for Metabase API
const metabaseAxios = axios.create({
baseURL: getEnvUrl("VITE_CHRONICLE_URL"),
});
// Add auth token interceptor
metabaseAxios.interceptors.request.use((config) => {
const token = Cookies.get("auth-token");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
// Add tenant ID header
config.headers["x-tenant-id"] = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
return config;
});
class MetabaseService {
private baseUrl = "/metabase-dashboards";
async createDashboard(
data: CreateDashboardRequest,
): Promise<MetabaseDashboard> {
const response: AxiosResponse<MetabaseDashboard> = await metabaseAxios.post(
this.baseUrl,
data,
);
return response.data;
}
async getAllDashboards(): Promise<MetabaseDashboardsResponse> {
const response: AxiosResponse<MetabaseDashboardsResponse> =
await metabaseAxios.get(this.baseUrl);
return response.data;
}
async getPermittedDashboards(
locale: string = "en",
): Promise<MetabaseDashboardsResponse> {
const response: AxiosResponse<MetabaseDashboardsResponse> =
await metabaseAxios.get(`${this.baseUrl}/permitted`, {
params: {
locale,
},
});
return response.data;
}
async getIframeUrl(
dashboardId: string,
params: GetIframeUrlRequest,
): Promise<MetabaseIframeResponse> {
const response: AxiosResponse<MetabaseIframeResponse> =
await metabaseAxios.post(
`${this.baseUrl}/${dashboardId}/iframe-url`,
params,
);
return response.data;
}
async getFilteredDashboardUrl(
dashboardId: string,
planYearId: string,
): Promise<MetabaseIframeResponse> {
const params: GetIframeUrlRequest = {
params: {
plan_year: planYearId,
},
};
const response: AxiosResponse<MetabaseIframeResponse> =
await metabaseAxios.post(
`${this.baseUrl}/${dashboardId}/iframe-url`,
params,
);
return response.data;
}
}
export const metabaseService = new MetabaseService();
// Helper function to create a performance dashboard
export const createPerformanceDashboard = async () => {
const dashboardData: CreateDashboardRequest = {
name: {
am: "performance",
en: "performance",
},
metabaseDashboardNumber: 2,
description: {
am: "performance",
en: "performance",
},
permissionId: "a83825b9-9806-4918-bc10-56226bbe41d4",
params: [],
};
return metabaseService.createDashboard(dashboardData);
};

View File

@@ -0,0 +1,65 @@
import { Axios, AxiosResponse } from "axios";
import performanceInstance from "@/shared/services/performanceInstance";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import DateMeasurementUnit from "@/performance-management/utils/data-measurement-unit";
export interface CreateServiceDto {
id?: string; // Optional for create, required for update
slug: string;
prefix: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
organizationId: string;
serviceCategoryId?: string;
parentServiceId?: string;
estimatedTime: number;
estimatedTimeUnit: DateMeasurementUnit;
}
export const createService = async (
data: CreateServiceDto
): Promise<AxiosResponse> => {
return performanceInstance.post("/services", data, {
headers: withHeaders(),
});
};
export const getService = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.get(`/services/${id}`, {
headers: withHeaders(),
});
};
export const getServiceList = async (
organizationId: string,
serviceCategoryId?: string,
parentServiceId?: string
): Promise<AxiosResponse> => {
const params: any = {};
if (serviceCategoryId) params.serviceCategoryId = serviceCategoryId;
if (parentServiceId) params.parentServiceId = parentServiceId;
return performanceInstance.get(`/services/list/${organizationId}`, {
headers: withHeaders(),
params,
});
};
export const updateService = async (
data: CreateServiceDto
): Promise<AxiosResponse> => {
return performanceInstance.put(`/services/${data.id}`, data, {
headers: withHeaders(),
});
};
export const deleteService = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.delete(`/services/${id}`, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,155 @@
import { SectorListParam } from "@/performance-management/types/planTypes";
import PlanType from "@/performance-management/utils/plan-unit";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import performanceInstance from "@/shared/services/performanceInstance";
import { AxiosResponse } from "axios";
export interface PlanParams {
serviceId?: string;
positionId?: string;
parentPlanId?: string;
skip?: number;
take?: number;
orderBy?: string;
planFor?: "team" | "self" | "position";
}
export interface CreatePlanDto {
id?: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
positionId?: string;
parentPlanId?: string; //
planType: PlanType; //
planYearId: string; //
// initialGoal: number;
// expectedGoal: number;
weight: number;
month?: number;
week?: number;
day?: number;
quarter?: number;
timeframe: string;
}
export const createPlan = async (
data: CreatePlanDto,
): Promise<AxiosResponse> => {
return performanceInstance.post("/plans", data, {
headers: withHeaders(),
});
};
export const updatePlan = async (
data: CreatePlanDto,
): Promise<AxiosResponse> => {
return performanceInstance.put(`/plans/${data?.id}`, data, {
headers: withHeaders(),
});
};
export const requestApproval = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.post(
`/plans/${id}/sent`,
{},
{
headers: withHeaders(),
},
);
};
export const approvePlan = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.post(
`/plans/${id}/approve`,
{},
{
headers: withHeaders(),
},
);
};
export const rejectPlan = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.post(
`/plans/${id}/reject`,
{},
{
headers: withHeaders(),
},
);
};
export const deletePlan = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.delete(`/plans/${id}`, {
headers: withHeaders(),
});
};
export const getPlan = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/${id}`, {
headers: withHeaders(),
});
};
export const getPlanList = async (
id: string,
params?: PlanParams,
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/list/${id}`, {
headers: withHeaders(),
params,
});
};
export const getSectorList = async (
id?: string,
params?: SectorListParam,
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/${id}/sector`, {
headers: withHeaders(),
params,
});
};
export const getMinePlan = async (
params?: SectorListParam,
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/mine`, {
headers: withHeaders(),
params,
});
};
export const getSubPlans = async (
id: string,
params?: SectorListParam,
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/${id}/sub-plans`, {
headers: withHeaders(),
params,
});
};
export const getMySubPlans = async (
id: string,
params?: SectorListParam,
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/${id}/mine-sub-plans`, {
headers: withHeaders(),
params,
});
};
export const getTeamSubPlans = async (
id: string,
params?: SectorListParam,
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plans/${id}/team-sub-plans`, {
headers: withHeaders(),
params,
});
};

View File

@@ -0,0 +1,120 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import performanceInstance from "@/shared/services/performanceInstance";
import { AxiosResponse } from "axios";
export interface CreatePlanTeamDto {
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
teamLeaderId: string;
positionId: string;
}
export interface CreatePlanTeamMemberDto {
planTeamId: string;
employeePositionIds: string;
}
export interface CreatePlanBulkTeamMemberDto {
planTeamId: string;
employeePositionIds: string[];
}
export interface PlanTeamParams {
skip?: number;
take?: number;
orderBy?: string;
}
export interface CreatePlanTeamMemberDto {
planTeamId: string;
employeePositionId: string;
}
export const getPlanTeam = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.get(`/plan-teams/${id}`, {
headers: withHeaders(),
});
};
export const updatePlanTeam = async (
id: string,
data: CreatePlanTeamDto
): Promise<AxiosResponse> => {
return performanceInstance.put(`/plan-teams/${id}`, data, {
headers: withHeaders(),
});
};
export const deletePlanTeam = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.delete(`/plan-teams/${id}`, {
headers: withHeaders(),
});
};
export const createPlanTeam = async (
data: CreatePlanTeamDto
): Promise<AxiosResponse> => {
return performanceInstance.post(`/plan-teams`, data, {
headers: withHeaders(),
});
};
export const getPlanTeamList = async (
id: string,
params?: PlanTeamParams
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plan-teams/list/${id}`, {
headers: withHeaders(),
params,
});
};
export const getPlanTeamMembers = async (
id: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plan-team-members/${id}`, {
headers: withHeaders(),
});
};
export const createPlanTeamMember = async (
data: CreatePlanTeamMemberDto
): Promise<AxiosResponse> => {
return performanceInstance.post(`/plan-team-members`, data, {
headers: withHeaders(),
});
};
export const createPlanBulkTeamMember = async (
data: CreatePlanBulkTeamMemberDto
): Promise<AxiosResponse> => {
return performanceInstance.post(`/plan-team-members/bulk-create`, data, {
headers: withHeaders(),
});
};
export const deletePlanTeamMember = async (
id: string
): Promise<AxiosResponse> => {
return performanceInstance.delete(`/plan-team-members/${id}`, {
headers: withHeaders(),
});
};
export const updatePlanTeamMember = async (
id: string,
data: CreatePlanTeamMemberDto
): Promise<AxiosResponse> => {
return performanceInstance.put(`/plan-team-members/${id}`, data, {
headers: withHeaders(),
});
};
export const getPlanTeamMemberList = async (
id: string,
params?: PlanTeamParams
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plan-team-members/list/${id}`, {
headers: withHeaders(),
params,
});
};

View File

@@ -0,0 +1,90 @@
import { withHeaders } from "@/record-management/services/api/withHeaders";
import performanceInstance from "@/shared/services/performanceInstance";
import { AxiosResponse } from "axios";
import { CreatePlanYearDto } from "../../types/planYearTypes";
export interface PlanYearParams {
skip?: number;
take?: number;
orderBy?: string;
isActive?: boolean;
}
// Get all plan years
export const getPlanYears = async (
organizationId: string,
params?: PlanYearParams
): Promise<AxiosResponse> => {
return performanceInstance.get(`/plan-years/list/${organizationId}`, {
headers: withHeaders(),
params,
});
};
// Get single plan year by ID
export const getPlanYear = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.get(`/plan-years/${id}`, {
headers: withHeaders(),
});
};
// Create new plan year
export const createPlanYear = async (
data: CreatePlanYearDto
): Promise<AxiosResponse> => {
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve({
// data: { ...data, id: `py-${data.year}` },
// status: 201,
// statusText: "Created",
// headers: {},
// config: {} as any,
// });
// }, 500);
// });
return performanceInstance.post("/plan-years", data, {
headers: withHeaders(),
});
};
// Update existing plan year
export const updatePlanYear = async (
data: CreatePlanYearDto
): Promise<AxiosResponse> => {
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve({
// data: data,
// status: 200,
// statusText: "OK",
// headers: {},
// config: {} as any,
// });
// }, 500);
// });
return performanceInstance.put(`/plan-years/${data.id}`, data, {
headers: withHeaders(),
});
};
// Delete plan year
export const deletePlanYear = async (id: string): Promise<AxiosResponse> => {
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve({
// data: { success: true },
// status: 200,
// statusText: "OK",
// headers: {},
// config: {} as any,
// });
// }, 500);
// });
return performanceInstance.delete(`/plan-years/${id}`, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,125 @@
import performanceInstance from "@/shared/services/performanceInstance";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import { AxiosResponse } from "axios";
export interface ServiceAssignment {
id: string;
positionId: string;
serviceId: string;
assignedAt: string;
}
export interface AssignServicesPayload {
positionId: string;
serviceIds: string[];
}
/**
* Fetch services assigned to a specific position
* Returns the service (second) assigned to a position (first)
*/
export const getAssignedServices = async (
positionId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(
`/position-services/seconds-for-first/${positionId}`,
{
headers: withHeaders(),
}
);
};
/**
* Assign service(s) to a position
* API expects: firstIds (positions array) and secondId (service)
*/
export const assignServicesToPosition = async (
positionId: string,
serviceIds: string[]
): Promise<AxiosResponse> => {
return performanceInstance.post(
`/position-services/assign-firsts-for-second`,
{
firstIds: [positionId],
secondId: serviceIds[0], // Single service ID
},
{
headers: withHeaders(),
}
);
};
/**
* Remove a service assignment from a position
*/
export const removeServiceFromPosition = async (
positionId: string,
serviceId: string
): Promise<AxiosResponse> => {
return performanceInstance.delete(
`/positions/${positionId}/services/${serviceId}`,
{
headers: withHeaders(),
}
);
};
/**
* Get all services for a specific organization
*/
export const getServicesByOrganization = async (
organizationId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/services/list/${organizationId}`, {
headers: withHeaders(),
});
};
// Keep backward compatibility
export const getServicesByUnit = getServicesByOrganization;
/**
* Get all services (active and published) - deprecated, use getServicesByUnit
*/
export const getAvailableServices = async (): Promise<AxiosResponse> => {
return performanceInstance.get("/services", {
headers: withHeaders(),
params: {
isActive: true,
status: "PUBLISHED",
},
});
};
/**
* Get services associated with a position (using position endpoint)
*/
export const getPositionServices = async (
positionId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/position-services/position/${positionId}`, {
headers: withHeaders(),
});
};
/**
* Get position services by position ID (for working days configuration)
*/
export const getPositionServicesByPositionId = async (
positionId: string
): Promise<AxiosResponse> => {
return performanceInstance.get(`/position-services/position/${positionId}`, {
headers: withHeaders(),
});
};
/**
* Delete a position service by ID
*/
export const deletePositionService = async (
positionServiceId: string
): Promise<AxiosResponse> => {
return performanceInstance.delete(`/position-services/${positionServiceId}`, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,66 @@
import { AxiosResponse } from "axios";
import performanceInstance from "@/shared/services/performanceInstance";
import { withHeaders } from "@/record-management/services/api/withHeaders";
export interface ServiceCategory {
id: string;
slug: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
organizationId: string;
createdAt: string;
updatedAt?: string;
}
export interface CreateServiceCategoryRequest {
slug: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
organizationId: string;
}
export const serviceCategoryService = {
create: async (
data: CreateServiceCategoryRequest
): Promise<AxiosResponse> => {
return performanceInstance.post("/service-categories", data, {
headers: withHeaders(),
});
},
getList: async (organizationId: string): Promise<AxiosResponse> => {
return performanceInstance.get(
`/service-categories/list/${organizationId}`,
{
headers: withHeaders(),
}
);
},
update: async (
id: string,
data: CreateServiceCategoryRequest
): Promise<AxiosResponse> => {
return performanceInstance.put(`/service-categories/${id}`, data, {
headers: withHeaders(),
});
},
delete: async (id: string): Promise<AxiosResponse> => {
return performanceInstance.delete(`/service-categories/${id}`, {
headers: withHeaders(),
});
},
};

View File

@@ -0,0 +1,52 @@
import { AxiosResponse } from "axios";
import performanceInstance from "@/shared/services/performanceInstance";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import { CreateWorkingHoursDto } from "@/performance-management/types/workingHoursTypes";
export const createWorkingHours = async (
data: CreateWorkingHoursDto
): Promise<AxiosResponse> => {
return performanceInstance.post("/working-days", data, {
headers: withHeaders(),
});
};
export const getWorkingHours = async (id: string): Promise<AxiosResponse> => {
return performanceInstance.get(`/working-days/${id}`, {
headers: withHeaders(),
});
};
export const getWorkingHoursList = async (): Promise<AxiosResponse> => {
return performanceInstance.get("/working-days", {
headers: withHeaders(),
});
};
export const getWorkingHoursByPositionServiceId = async (
positionServiceId: string
): Promise<AxiosResponse> => {
return performanceInstance.get("/working-days", {
headers: withHeaders(),
params: {
positionServiceId,
},
});
};
export const updateWorkingHours = async (
id: string,
data: CreateWorkingHoursDto
): Promise<AxiosResponse> => {
return performanceInstance.put(`/working-days/${id}`, data, {
headers: withHeaders(),
});
};
export const deleteWorkingHours = async (
id: string
): Promise<AxiosResponse> => {
return performanceInstance.delete(`/working-days/${id}`, {
headers: withHeaders(),
});
};

View File

@@ -0,0 +1,59 @@
export interface Plan {
id: string;
name: string;
description?: string;
weight: number;
timeline: string;
status: "pending" | "in-progress" | "completed" | "overdue";
progress: number;
}
export interface WeeklyTask {
id: string;
description: string;
deadline: string;
status: "pending" | "in-progress" | "completed";
expectedOutcome: string;
}
export interface Employee {
id: string;
name: string;
position: string;
planCount: number;
weeklyTasksCount: number;
plans: Plan[];
weeklyTasks: WeeklyTask[];
}
export interface TeamLead {
id: string;
name: string;
planCount: number;
progress: number;
plans: Plan[];
weeklyMilestones: string[];
employees: Employee[];
}
export interface Directorate {
id: string;
name: string;
planCount: number;
progress: number;
plans: Plan[];
teamLeads: TeamLead[];
}
export interface Sector {
id: string;
name: string;
plans: Plan[];
directorates: Directorate[];
}
// View Data Interfaces
export interface DeputyData extends Sector {}
export interface DirectorData extends Directorate {}
export interface TeamLeaderData extends TeamLead {}
export interface EmployeeData extends Employee {}

View File

@@ -0,0 +1,104 @@
// types/plantypes.ts
export interface Plan {
createdAt: string;
updatedAt: string;
tenantId: string;
amharicCreatedAt: string;
amharicUpdatedAt: string;
id: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
planYearId: string;
// initialGoal?: number;
// expectedGoal?: number;
weight: number;
month: number;
week: number;
day: number;
quarter: number;
timeframe: number;
positionId: string;
unitId: string;
parentPlanId?: string;
planType?: string;
planFor?: string;
}
export enum BillingCycle {
MONTHLY = "MONTHLY",
QUARTERLY = "QUARTERLY",
ANNUALLY = "ANNUALLY",
ONE_TIME = "ONE_TIME",
}
export enum PlanStatus {
DRAFT = "DRAFT",
ACTIVE = "ACTIVE",
ARCHIVED = "ARCHIVED",
COMING_SOON = "COMING_SOON",
}
export enum SupportLevel {
BASIC = "BASIC",
PRIORITY = "PRIORITY",
PREMIUM = "PREMIUM",
DEDICATED = "DEDICATED",
}
export enum CancellationPolicy {
ANYTIME = "ANYTIME",
THIRTY_DAYS = "THIRTY_DAYS",
END_OF_TERM = "END_OF_TERM",
}
export interface SectorList {
position_created_at: string;
position_updated_at: string;
position_id: string;
position_name: {
am: string;
en: string;
};
position_key: string;
position_rank: number;
position_parent_position_id: string;
position_unit_id: string;
position_organization_id: string;
position_project_id: string | null;
position_position_type_id: string;
no_of_plans: number;
total_weight: number | null;
planTeam_name?: {
am: string;
en: string;
};
planTeam_description?: {
am: string;
en: string;
};
id?: string;
planTeam_id?: string;
positionId?: string;
planTeam_position_id?:string;
}
export interface SectorListResponse {
count: number;
items: SectorList[];
}
export interface SectorListParam {
positonId?: string;
organizationId?: string;
parentPlanId?: string;
take?: number;
skip?: number;
orderBy?: string;
planYearId?: string;
}

View File

@@ -0,0 +1,38 @@
export interface PlanYear {
id: string;
year: number;
name: {
am: string;
en: string;
};
description?: {
am: string;
en: string;
};
isActive: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface CreatePlanYearDto {
id?: string;
year: number;
name: {
am: string;
en: string;
};
description?: {
am: string;
en: string;
};
organizationId?: string;
}
export interface PlanYearFormValues {
year: number;
nameAm: string;
nameEn: string;
descriptionAm?: string;
descriptionEn?: string;
organizationId?: string;
}

View File

@@ -0,0 +1,45 @@
import DateMeasurementUnit from "../utils/data-measurement-unit";
export interface Service {
id: string;
name: string | { en: string; am: string }; // Can be string or localized object
description: string;
categoryId: string;
categoryName?: string;
isActive: boolean;
createdAt?: string;
updatedAt?: string;
// Add these missing properties
estimatedDuration?: number;
estimatedDurationUnit?: string;
status?: string;
parentId?: string;
requirements?: string;
}
export interface IServiceResponse {
createdAt: string;
updatedAt: string | null;
tenantId: string;
amharicCreatedAt: string | null;
amharicUpdatedAt: string | null;
id: string;
slug: string;
name: {
am: string;
en: string;
};
description: {
am: string;
en: string;
};
estimatedTime: number;
estimatedTimeUnit: DateMeasurementUnit;
organizationId: string | null;
serviceCategoryId?: string | null;
}

View File

@@ -0,0 +1,22 @@
export type WeekDay =
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday"
| "sunday";
export interface CreateWorkingHoursDto {
slug: string;
weekDay: WeekDay;
positionServiceId: string;
startTime: string; // Format: "HH:mm"
endTime: string; // Format: "HH:mm"
}
export interface WorkingHoursResponse extends CreateWorkingHoursDto {
id: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,14 @@
// utils/ethiopianYears.ts
export const generateEthiopianYearOptions = (yearsAhead = 5) => {
const today = new Date();
let currentYear = today.getFullYear() - 8;
const month = today.getMonth() + 1;
const day = today.getDate();
if (month > 9 || (month === 9 && day >= 11)) currentYear += 1;
return Array.from({ length: yearsAhead + 1 }, (_, i) => {
const year = currentYear + i;
return { label: year.toString(), value: year.toString() };
});
};

View File

@@ -0,0 +1,549 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { motion, AnimatePresence, Variants } from "framer-motion";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Textarea } from "@/shared/common/ui/textarea";
import { Switch } from "@/shared/common/ui/switch";
import { Checkbox } from "@/shared/common/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { MultiSelect } from "@/shared/common/ui/multi-select";
import { Calendar } from "@/shared/common/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
import { Tabs, TabsList, TabsTrigger } from "@/shared/common/ui/tabs";
import { format } from "date-fns";
import { CalendarIcon, AlertCircle } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { SliderUI } from "@/shared/common/ui/SliderUI";
import { FormField } from "./shared-field-types";
import RichTextEditor from "@/record-management/common/RichTextEditor";
import StarRating from "@/shared/common/ui/star-rating";
import HabeshaDatePicker from "@/shared/common/ui/habesha-date-picker";
import YearPicker from "@/shared/common/ui/year-picker";
import { useEffect } from "react";
interface FormFieldRendererProps<T extends FieldValues> {
field: FormField<T>;
form: UseFormReturn<T>;
isLoading?: boolean;
isSubmitting?: boolean;
defaultValue?: any;
shouldClearEditor?: boolean;
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
}
const itemVariants: Variants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: {
type: "spring" as const,
stiffness: 100,
damping: 15,
},
},
};
export function FormFieldRenderer<T extends FieldValues>({
field,
form,
isLoading = false,
isSubmitting = false,
defaultValue,
shouldClearEditor = false,
onScroll,
}: FormFieldRendererProps<T>) {
const {
register,
formState: { errors },
setValue,
watch,
getValues,
} = form;
const fieldName = field.name as Path<T>;
const fieldValue = watch(fieldName);
const hasError = !!errors[fieldName];
// Set default value when component mounts
useEffect(() => {
const currentValue = getValues(fieldName);
// If field has no value and we have a default value
if (
currentValue === undefined ||
currentValue === null ||
currentValue === ""
) {
let valueToSet = null;
// Priority 1: Field-specific defaultValue
if (field.defaultValue !== undefined && field.defaultValue !== null) {
valueToSet = field.defaultValue;
}
// Priority 2: Global form defaultValue for this field
else if (defaultValue !== undefined && defaultValue !== null) {
const globalDefault =
typeof defaultValue === "object"
? defaultValue[field.name as string]
: defaultValue;
if (globalDefault !== undefined && globalDefault !== null) {
valueToSet = globalDefault;
}
}
if (valueToSet !== null) {
setValue(fieldName, valueToSet, { shouldValidate: true });
}
}
}, [
fieldName,
fieldValue,
field.defaultValue,
defaultValue,
setValue,
getValues,
]);
// Get grid classes with proper defaults
const getGridClasses = () => {
const classes = [];
// Determine column spans with defaults
const colSpan = field.colSpan || 12;
const smColSpan = field.smColSpan || colSpan;
const lgColSpan = field.lgColSpan || colSpan;
// Special handling for textarea - make it full width by default
if (field.type === "textarea" && !field.colSpan) {
return "col-span-12 sm:col-span-12 lg:col-span-12";
}
// Map spans to Tailwind classes
const spanClasses: Record<number, string> = {
1: "col-span-1",
2: "col-span-2",
3: "col-span-3",
4: "col-span-4",
5: "col-span-5",
6: "col-span-6",
7: "col-span-7",
8: "col-span-8",
9: "col-span-9",
10: "col-span-10",
11: "col-span-11",
12: "col-span-12",
};
const smSpanClasses: Record<number, string> = {
1: "sm:col-span-1",
2: "sm:col-span-2",
3: "sm:col-span-3",
4: "sm:col-span-4",
5: "sm:col-span-5",
6: "sm:col-span-6",
7: "sm:col-span-7",
8: "sm:col-span-8",
9: "sm:col-span-9",
10: "sm:col-span-10",
11: "sm:col-span-11",
12: "sm:col-span-12",
};
const lgSpanClasses: Record<number, string> = {
1: "lg:col-span-1",
2: "lg:col-span-2",
3: "lg:col-span-3",
4: "lg:col-span-4",
5: "lg:col-span-5",
6: "lg:col-span-6",
7: "lg:col-span-7",
8: "lg:col-span-8",
9: "lg:col-span-9",
10: "lg:col-span-10",
11: "lg:col-span-11",
12: "lg:col-span-12",
};
// Add base column span
classes.push(spanClasses[colSpan] || "col-span-12");
// Add responsive spans if different from base
if (smColSpan !== colSpan) {
classes.push(smSpanClasses[smColSpan] || `sm:col-span-${smColSpan}`);
}
if (lgColSpan !== colSpan) {
classes.push(lgSpanClasses[lgColSpan] || `lg:col-span-${lgColSpan}`);
}
return classes.join(" ");
};
const renderFieldContent = (field: FormField<T>) => {
const formProps = register(fieldName);
const inputClasses = cn(
"transition-all duration-200 focus:ring-2 focus:ring-primary/20",
hasError && "border-destructive focus:ring-destructive/20"
);
switch (field.type) {
case "text":
return (
<Input
id={field.name as string}
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
autoComplete={field.autoComplete}
className={inputClasses}
{...formProps}
/>
);
case "textarea":
return (
<Textarea
id={field.name as string}
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
rows={field.rows || 4}
className={cn(inputClasses, "resize-none")}
{...formProps}
/>
);
case "number":
return (
<Input
id={field.name as string}
type="number"
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
min={field?.min}
max={field?.max}
step={field?.step}
className={inputClasses}
{...formProps}
/>
);
case "password":
return (
<Input
id={field.name as string}
type="password"
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
autoComplete={field.autoComplete}
className={inputClasses}
{...formProps}
/>
);
case "switch":
return (
<div className="flex items-center space-x-2">
<Switch
id={field.name as string}
disabled={field.disabled || isLoading || isSubmitting}
checked={fieldValue as boolean}
onCheckedChange={(checked) => {
setValue(fieldName, checked as any);
field.onChange?.(checked);
}}
/>
</div>
);
case "checkbox":
return (
<Checkbox
id={field.name as string}
disabled={field.disabled || isLoading || isSubmitting}
checked={fieldValue as boolean}
onCheckedChange={(checked) => {
setValue(fieldName, checked as any);
field.onChange?.(checked);
}}
/>
);
case "select":
return (
<Select
value={(fieldValue as string) || undefined}
onValueChange={(value) => {
setValue(fieldName, value as any);
field.onChange?.(value);
}}
disabled={field.disabled || isLoading || isSubmitting}>
<SelectTrigger className={cn("w-full", inputClasses)}>
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent
onScroll={onScroll}
className="max-h-60 overflow-y-auto">
{field.options?.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "multiselect":
return (
<MultiSelect
options={field.options || []}
value={fieldValue as string[]}
onValueChange={(value) => {
setValue(fieldName, value as any);
field.onChange?.(value);
}}
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
className={inputClasses}
/>
);
case "date":
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal transition-all duration-200",
!fieldValue && "text-muted-foreground",
inputClasses
)}
disabled={field.disabled || isLoading || isSubmitting}>
<CalendarIcon className="mr-2 h-4 w-4" />
{fieldValue ? (
format(fieldValue as Date, "PPP")
) : (
<span>{field.placeholder || "Pick a date"}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={fieldValue as Date}
onSelect={(date) => {
setValue(fieldName, date as any);
field.onChange?.(date);
}}
disabled={field.disabled || isLoading || isSubmitting}
initialFocus
/>
</PopoverContent>
</Popover>
);
// ... rest of your switch cases remain the same
case "radio":
return (
<RadioGroup
value={fieldValue as string}
onValueChange={(value) => {
setValue(fieldName, value as any);
field.onChange?.(value);
}}
disabled={field.disabled || isLoading || isSubmitting}
className={cn(
field.orientation === "horizontal"
? "flex space-x-4"
: "space-y-3",
"mt-2"
)}>
{field.data?.map((option: any) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem
value={option.value}
id={`${String(field.name)}-${option.value}`}
/>
<Label
htmlFor={`${String(field.name)}-${option.value}`}
className="font-normal cursor-pointer">
{option.label}
</Label>
</div>
)) ||
field.options?.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem
value={option.value}
id={`${String(field.name)}-${option.value}`}
/>
<Label
htmlFor={`${String(field.name)}-${option.value}`}
className="font-normal cursor-pointer">
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
case "rating":
return (
<StarRating
value={fieldValue as number}
onChange={(rating) => {
setValue(fieldName, rating as any);
field.onChange?.(rating);
}}
maxRating={field.count || 5}
disabled={field.disabled || isLoading || isSubmitting}
labels={field.labels}
language={field.language || "en"}
size={field.size || "md"}
showLabels={field.showLabels !== false}
className={inputClasses}
/>
);
case "habesha-date":
// Ensure we have a valid Date object or undefined
const dateValue = (() => {
if (
fieldValue &&
typeof fieldValue === "object" &&
fieldValue.constructor === Date &&
!isNaN((fieldValue as Date).getTime())
) {
return fieldValue as Date;
}
if (fieldValue && typeof fieldValue === "string") {
const parsed = new Date(fieldValue);
return !isNaN(parsed.getTime()) ? parsed : undefined;
}
return undefined;
})();
return (
<HabeshaDatePicker
value={dateValue}
onChange={(date) => {
setValue(fieldName, date as any);
field.onChange?.(date);
}}
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
language={field.language || "am"}
className={inputClasses}
/>
);
case "year-picker":
return (
<YearPicker
value={fieldValue as number}
onChange={(year) => {
setValue(fieldName, year as any);
field.onChange?.(year);
}}
placeholder={field.placeholder}
disabled={field.disabled || isLoading || isSubmitting}
calendarType={field.calendarType || "habesha"}
language={field.language || "am"}
minYear={field.minYear}
maxYear={field.maxYear}
className={inputClasses}
/>
);
// ... other cases remain unchanged
default:
return null;
}
};
const fieldKey = field.name as string;
const gridClasses = getGridClasses();
const isCheckable = field.type === "checkbox" || field.type === "switch";
return (
<motion.div
key={fieldKey}
variants={itemVariants}
className={cn(gridClasses, field.className, "flex flex-col gap-2 group")}>
{isCheckable ? (
<div
className={cn(
"flex flex-row items-start space-x-3 space-y-0 rounded-lg border p-4 transition-all duration-200 hover:bg-muted/30",
hasError
? "border-destructive/50 bg-destructive/5"
: "border-border"
)}>
{renderFieldContent(field)}
<div className="space-y-1 leading-none cursor-pointer">
<Label
htmlFor={field.name as string}
className="cursor-pointer font-medium">
{field.label}
{field.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{field.description && (
<p className="text-sm text-muted-foreground">
{field.description}
</p>
)}
</div>
</div>
) : (
<>
<div className="flex justify-between items-baseline">
<Label
htmlFor={field.name as string}
className="text-sm font-medium text-foreground/80 group-focus-within:text-primary transition-colors">
{field.label}
{field.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
</div>
{field.description && (
<p className="text-xs text-muted-foreground -mt-1 mb-1">
{field.description}
</p>
)}
{renderFieldContent(field)}
</>
)}
<AnimatePresence mode="wait">
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex items-center text-sm font-medium text-destructive mt-1">
<AlertCircle className="w-4 h-4 mr-1.5" />
{errors[fieldName]?.message as string}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -0,0 +1,148 @@
// @ts-ignore - Tiptap type declarations issue
import { useEditor, EditorContent } from "@tiptap/react";
// @ts-ignore - Tiptap type declarations issue
import StarterKit from "@tiptap/starter-kit";
// @ts-ignore - Tiptap type declarations issue
import Underline from "@tiptap/extension-underline";
// @ts-ignore - Tiptap type declarations issue
import Link from "@tiptap/extension-link";
// @ts-ignore - Tiptap type declarations issue
import TextAlign from "@tiptap/extension-text-align";
// @ts-ignore - Tiptap type declarations issue
import Image from "@tiptap/extension-image";
// @ts-ignore - Tiptap type declarations issue
import { Color } from "@tiptap/extension-color";
// @ts-ignore - Tiptap type declarations issue
import { TextStyle } from "@tiptap/extension-text-style";
// @ts-ignore - Tiptap type declarations issue
import Highlight from "@tiptap/extension-highlight";
import { useEffect } from "react";
import { cn } from "@/shared/lib/utils";
interface RichTextViewerProps {
content: string;
minHeight?: number;
className?: string;
variant?: "default" | "minimal" | "bordered";
}
const RichTextViewer = ({
content,
minHeight = 200,
className,
variant = "default",
}: RichTextViewerProps) => {
// @ts-ignore - Tiptap type declarations issue
const extensions = [
// @ts-ignore
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
// @ts-ignore
Underline.configure(),
// @ts-ignore
TextStyle.configure(),
// @ts-ignore
Highlight.configure({ multicolor: true }),
// @ts-ignore
Color.configure(),
// @ts-ignore
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: "noopener noreferrer",
target: "_blank",
class: "text-blue-600 hover:text-blue-800 underline",
},
}),
// @ts-ignore
TextAlign.configure({
types: ["heading", "paragraph"],
alignments: ["left", "center", "right", "justify"],
}),
// @ts-ignore
Image.configure({
inline: true,
allowBase64: true,
HTMLAttributes: {
class: "rounded-md max-w-full h-auto",
},
}),
];
const editor = useEditor({
extensions,
content,
editable: false,
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return (
<div
className={cn(
"animate-pulse bg-gray-200 rounded-md",
"dark:bg-gray-700",
className
)}
style={{ minHeight: `${minHeight}px` }}
/>
);
}
const variantStyles = {
default:
"border border-gray-200 rounded-md bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900",
minimal: "bg-transparent",
bordered: "border border-gray-200 rounded-md dark:border-gray-700",
};
return (
<div
className={cn(variantStyles[variant], className)}
style={{ minHeight: `${minHeight}px` }}>
<EditorContent
editor={editor}
className={cn(
"px-6 py-4 min-h-[200px]",
// Base typography styles
"[&_.tiptap]:outline-none",
"[&_.tiptap_h1]:text-2xl [&_.tiptap_h1]:font-bold [&_.tiptap_h1]:my-4",
"[&_.tiptap_h2]:text-xl [&_.tiptap_h2]:font-bold [&_.tiptap_h2]:my-3",
"[&_.tiptap_h3]:text-lg [&_.tiptap_h3]:font-bold [&_.tiptap_h3]:my-2",
"[&_.tiptap_p]:my-2 [&_.tiptap_p]:leading-relaxed",
"[&_.tiptap_ul]:my-2 [&_.tiptap_ul]:list-disc [&_.tiptap_ul]:pl-6",
"[&_.tiptap_ol]:my-2 [&_.tiptap_ol]:list-decimal [&_.tiptap_ol]:pl-6",
"[&_.tiptap_li]:my-1",
"[&_.tiptap_blockquote]:border-l-4 [&_.tiptap_blockquote]:border-gray-300",
"[&_.tiptap_blockquote]:pl-4 [&_.tiptap_blockquote]:italic [&_.tiptap_blockquote]:my-2",
"[&_.tiptap_code]:bg-gray-100 [&_.tiptap_code]:px-1 [&_.tiptap_code]:rounded",
"[&_.tiptap_code]:text-sm dark:[&_.tiptap_code]:bg-gray-800",
"[&_.tiptap_pre]:bg-gray-900 [&_.tiptap_pre]:text-white [&_.tiptap_pre]:p-4",
"[&_.tiptap_pre]:rounded-md [&_.tiptap_pre]:my-2 dark:[&_.tiptap_pre]:bg-gray-800",
"[&_.tiptap_strong]:font-bold",
"[&_.tiptap_em]:italic",
"[&_.tiptap_underline]:underline",
"[&_.tiptap_highlight]:bg-yellow-200 dark:[&_.tiptap_highlight]:bg-yellow-800",
"[&_.tiptap_table]:border-collapse [&_.tiptap_table]:border [&_.tiptap_table]:border-gray-300",
"[&_.tiptap_th]:bg-gray-100 [&_.tiptap_th]:p-2 dark:[&_.tiptap_th]:bg-gray-800",
"[&_.tiptap_td]:p-2 [&_.tiptap_td]:border [&_.tiptap_td]:border-gray-300",
"dark:[&_.tiptap_td]:border-gray-700",
"[&_.tiptap_img]:rounded-md [&_.tiptap_img]:max-w-full [&_.tiptap_img]:h-auto",
// Dark mode support
"dark:[&_.tiptap]:text-gray-100",
"dark:[&_.tiptap_blockquote]:border-gray-600"
)}
/>
</div>
);
};
export default RichTextViewer;

View File

@@ -0,0 +1,93 @@
import { MetabaseDashboard } from "../services/api/metabaseService";
/**
* Utility functions for Metabase dashboard management
*/
/**
* Find dashboard by metabase dashboard number
*/
export const findDashboardByNumber = (
dashboards: MetabaseDashboard[],
dashboardNumber: number
): MetabaseDashboard | undefined => {
return dashboards.find((d) => d.metabaseDashboardNumber === dashboardNumber);
};
/**
* Display-only labels for permitted Metabase dashboards.
*/
export const formatDashboardDisplayName = (name: string): string => {
if (!name?.trim()) return name;
return name
.replace(/position\s+performance/gi, "Team Leader Performance")
.replace(/employee\s+performance/gi, "Expert Performance")
.replace(/^position$/i, "Team Leader")
.replace(/^employee$/i, "Expert");
};
export const getFormattedDashboardLabel = (
name: string | { en?: string; am?: string } | undefined,
locale: "en" | "am" = "en",
): string => {
if (!name) return "Dashboard";
const raw =
typeof name === "string"
? name
: (locale === "am" ? name.am : name.en) || name.en || name.am || "Dashboard";
return formatDashboardDisplayName(raw);
};
/**
* Get dashboard display name (prefers English, falls back to Amharic)
*/
export const getDashboardDisplayName = (
dashboard: MetabaseDashboard,
): string => {
const raw =
dashboard.name.en ||
dashboard.name.am ||
`Dashboard #${dashboard.metabaseDashboardNumber}`;
return formatDashboardDisplayName(raw);
};
/**
* Get dashboard description (prefers English, falls back to Amharic)
*/
export const getDashboardDescription = (
dashboard: MetabaseDashboard
): string => {
return dashboard.description.en || dashboard.description.am || "";
};
/**
* Check if dashboard has parameters
*/
export const hasParameters = (dashboard: MetabaseDashboard): boolean => {
return dashboard.params && dashboard.params.length > 0;
};
/**
* Sort dashboards by dashboard number
*/
export const sortDashboardsByNumber = (
dashboards: MetabaseDashboard[]
): MetabaseDashboard[] => {
return [...dashboards].sort(
(a, b) => a.metabaseDashboardNumber - b.metabaseDashboardNumber
);
};
/**
* Filter dashboards by tenant ID
*/
export const filterDashboardsByTenant = (
dashboards: MetabaseDashboard[],
tenantId: string
): MetabaseDashboard[] => {
return dashboards.filter((d) => d.tenantId === tenantId);
};

View File

@@ -0,0 +1,11 @@
enum DateMeasurementUnit {
MINUTES = "minute",
HOURS = "hour",
DAY = "day",
WEEKS = "weeks",
YEAR = "year",
MONTH = "month",
SECOND = "second",
}
export default DateMeasurementUnit;

View File

@@ -0,0 +1,29 @@
//
export const formatDateForApi = (date: Date): string => {
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const yyyy = date.getFullYear();
return `${mm}/${dd}/${yyyy}`;
};
export const getDefaultStartDate = (daysAgo: number = 30): Date => {
const today = new Date();
today.setDate(today.getDate() - daysAgo);
return today;
};
export const getDateRange = (range: "week" | "month" | "quarter"): Date => {
const today = new Date();
switch (range) {
case "week":
today.setDate(today.getDate() - 7);
break;
case "month":
today.setDate(today.getDate() - 30);
break;
case "quarter":
today.setDate(today.getDate() - 90);
break;
}
return today;
};

View File

@@ -0,0 +1,422 @@
/**
* Ethiopian Calendar Month Calculator
* Maps Ethiopian months to Gregorian months and calculates available months based on date ranges
*/
// Ethiopian months mapping to Gregorian months
// Ethiopian calendar: Meskerem (1) - Pagume (13)
// Gregorian mapping: Sep (9) - Aug (8) of next year
// Amharic month names have multiple accepted spellings (e.g. ሚያዝያ vs ሚያዚያ,
// ነሐሴ vs ነሃሴ). Each entry lists every spelling we have seen from the backend
// so the parser does not silently fall back to month=1.
const ETHIOPIAN_MONTHS = [
{
ethMonth: 1,
name: "Meskerem",
amharicNames: ["መስከረም"],
abbr: "MES",
gregMonth: 9,
gregMonthName: "September",
},
{
ethMonth: 2,
name: "Tekemt",
amharicNames: ["ጥቅምት"],
abbr: "TEK",
gregMonth: 10,
gregMonthName: "October",
},
{
ethMonth: 3,
name: "Hedar",
amharicNames: ["ኅዳር", "ህዳር", "ሕዳር"],
abbr: "HED",
gregMonth: 11,
gregMonthName: "November",
},
{
ethMonth: 4,
name: "Tahsas",
amharicNames: ["ታኅሣሥ", "ታህሳስ", "ታህሳሥ"],
abbr: "TAH",
gregMonth: 12,
gregMonthName: "December",
},
{
ethMonth: 5,
name: "Tir",
amharicNames: ["ጥር"],
abbr: "TIR",
gregMonth: 1,
gregMonthName: "January",
},
{
ethMonth: 6,
name: "Yekatit",
amharicNames: ["የካቲት"],
abbr: "YEK",
gregMonth: 2,
gregMonthName: "February",
},
{
ethMonth: 7,
name: "Megabit",
amharicNames: ["መጋቢት"],
abbr: "MEG",
gregMonth: 3,
gregMonthName: "March",
},
{
ethMonth: 8,
name: "Miazia",
amharicNames: ["ሚያዝያ", "ሚያዚያ"],
abbr: "MIA",
gregMonth: 4,
gregMonthName: "April",
},
{
ethMonth: 9,
name: "Genbot",
amharicNames: ["ግንቦት"],
abbr: "GEN",
gregMonth: 5,
gregMonthName: "May",
},
{
ethMonth: 10,
name: "Sene",
amharicNames: ["ሰኔ"],
abbr: "SEN",
gregMonth: 6,
gregMonthName: "June",
},
{
ethMonth: 11,
name: "Hamle",
amharicNames: ["ሐምሌ", "ሃምሌ"],
abbr: "HAM",
gregMonth: 7,
gregMonthName: "July",
},
{
ethMonth: 12,
name: "Nehase",
amharicNames: ["ነሐሴ", "ነሃሴ"],
abbr: "NEH",
gregMonth: 8,
gregMonthName: "August",
},
{
ethMonth: 13,
name: "Pagume",
amharicNames: ["ጳጉሜ", "ጳጉሜን"],
abbr: "PAG",
gregMonth: 9,
gregMonthName: "September (next year)",
},
];
// Gregorian to Ethiopian month mapping (for reverse lookup)
const GREGORIAN_TO_ETHIOPIAN: { [key: number]: number } = {
9: 1, // September -> Meskerem
10: 2, // October -> Tekemt
11: 3, // November -> Hedar
12: 4, // December -> Tahsas
1: 5, // January -> Tir
2: 6, // February -> Yekatit
3: 7, // March -> Megabit
4: 8, // April -> Miazia
5: 9, // May -> Genbot
6: 10, // June -> Sene
7: 11, // July -> Hamle
8: 12, // August -> Nehase
};
// Gregorian month keys used in the API (Jul-Jun cycle)
const GREGORIAN_MONTH_KEYS = [
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
];
export const monthsConfig: MonthConfig[] = [
{
key: "jul",
name: "July",
abbr: "JUL",
index: 1,
apiKey: "planned_jul",
value: 11,
},
{
key: "aug",
name: "August",
abbr: "AUG",
index: 2,
apiKey: "planned_aug",
value: 12,
},
{
key: "sep",
name: "September",
abbr: "SEP",
index: 3,
apiKey: "planned_sep",
value: 1,
},
{
key: "oct",
name: "October",
abbr: "OCT",
index: 4,
apiKey: "planned_oct",
value: 2,
},
{
key: "nov",
name: "November",
abbr: "NOV",
index: 5,
apiKey: "planned_nov",
value: 3,
},
{
key: "dec",
name: "December",
abbr: "DEC",
index: 6,
apiKey: "planned_dec",
value: 4,
},
{
key: "jan",
name: "January",
abbr: "JAN",
index: 7,
apiKey: "planned_jan",
value: 5,
},
{
key: "feb",
name: "February",
abbr: "FEB",
index: 8,
apiKey: "planned_feb",
value: 6,
},
{
key: "mar",
name: "March",
abbr: "MAR",
index: 9,
apiKey: "planned_mar",
value: 7,
},
{
key: "apr",
name: "April",
abbr: "APR",
index: 10,
apiKey: "planned_apr",
value: 8,
},
{
key: "may",
name: "May",
abbr: "MAY",
index: 11,
apiKey: "planned_may",
value: 9,
},
{
key: "jun",
name: "June",
abbr: "JUN",
index: 12,
apiKey: "planned_jun",
value: 10,
},
];
interface MonthConfig {
key: string;
name: string;
abbr: string;
index: number;
apiKey: string;
value: number;
ethMonth?: number;
ethMonthName?: string;
}
/**
* Parse Ethiopian date string. Supports both:
* "ሚያዚያ 01/2018 - 09:00 ሌሊት" (slash between day and year)
* "ታኅሣሥ 06: 2018 - 12:51 ማታ" (legacy: colon between day and year)
*/
export const parseEthiopianDateString = (
dateStr: string
): { year: number; month: number; day: number } | null => {
try {
const trimmed = dateStr.trim();
// Match "<MonthName> <DD><sep><YYYY>" where <sep> is "/" or ":"
const match = trimmed.match(/^(\S+)\s+(\d+)\s*[\/:]\s*(\d+)/);
if (!match) return null;
const [, monthName, dayStr, yearStr] = match;
const monthConfig = ETHIOPIAN_MONTHS.find(
(m) => m.name === monthName || m.amharicNames.includes(monthName)
);
if (!monthConfig) {
console.warn(
`Unknown Ethiopian month name "${monthName}" in "${dateStr}"`
);
}
return {
year: parseInt(yearStr),
month: monthConfig?.ethMonth || 1,
day: parseInt(dayStr),
};
} catch (error) {
console.error("Error parsing Ethiopian date:", error);
return null;
}
};
/**
* Calculate available months based on start and end dates
* Returns filtered monthsConfig with only available months
*/
export const calculateAvailableMonths = (
startDateStr: string,
endDateStr: string,
allMonthsConfig: MonthConfig[]
): MonthConfig[] => {
try {
const startDate = parseEthiopianDateString(startDateStr);
const endDate = parseEthiopianDateString(endDateStr);
if (!startDate || !endDate) {
console.warn("Could not parse dates, returning all months");
return allMonthsConfig;
}
console.log("Start Date (Ethiopian):", startDate);
console.log("End Date (Ethiopian):", endDate);
// If same year, filter months between start and end
if (startDate.year === endDate.year) {
return allMonthsConfig.filter((month) => {
const monthNum = month.value; // value represents Ethiopian month number
return monthNum >= startDate.month && monthNum <= endDate.month;
});
}
// If spanning multiple years, include all months from start to end
// This handles cases where the plan spans across Ethiopian year boundary
return allMonthsConfig.filter((month) => {
const monthNum = month.value;
// Include months from start month onwards in start year
if (monthNum >= startDate.month) {
return true;
}
// Include months up to end month in end year
if (monthNum <= endDate.month) {
return true;
}
return false;
});
} catch (error) {
console.error("Error calculating available months:", error);
return allMonthsConfig;
}
};
/**
* Filter months config to only include months that have employee plans
* @param allMonthsConfig - All available months
* @param employeePlanMonths - Array of month numbers from employee plans
* @returns Filtered months config
*/
export const filterMonthsByEmployeePlans = (
allMonthsConfig: MonthConfig[],
employeePlanMonths: number[]
): MonthConfig[] => {
console.log("filterMonthsByEmployeePlans called with:", {
allMonthsConfigLength: allMonthsConfig.length,
employeePlanMonths,
monthValues: allMonthsConfig.map((m) => ({ key: m.key, value: m.value })),
});
if (!employeePlanMonths || employeePlanMonths.length === 0) {
console.log("No employee plan months provided, returning all months");
return allMonthsConfig;
}
const filtered = allMonthsConfig.filter((month) => {
const matches = employeePlanMonths.includes(month.value);
console.log(
`Month ${month.key} (value: ${month.value}) matches: ${matches}`
);
return matches;
});
console.log(
"Filtered result:",
filtered.map((m) => ({ key: m.key, value: m.value }))
);
return filtered;
};
/**
* Get month config with Ethiopian month information
*/
export const getEnhancedMonthsConfig = (
baseConfig: MonthConfig[]
): MonthConfig[] => {
return baseConfig.map((month) => {
const ethMonth = ETHIOPIAN_MONTHS.find(
(em) =>
em.gregMonth === parseInt(month.name.substring(0, 2)) ||
em.abbr === month.abbr
);
return {
...month,
ethMonth: ethMonth?.ethMonth,
ethMonthName: ethMonth?.name,
};
});
};
/**
* Convert Gregorian month number to Ethiopian month number
*/
export const gregMonthToEthMonth = (gregMonth: number): number => {
return GREGORIAN_TO_ETHIOPIAN[gregMonth] || 1;
};
/**
* Get month name in both Gregorian and Ethiopian
*/
export const getMonthDisplayName = (monthKey: string): string => {
const monthIndex = GREGORIAN_MONTH_KEYS.indexOf(monthKey);
if (monthIndex === -1) return monthKey;
const ethMonth = ETHIOPIAN_MONTHS[monthIndex];
return `${monthKey.toUpperCase()} (${ethMonth.name})`;
};

View File

@@ -0,0 +1,99 @@
export type FormFieldType =
| "text"
| "textarea"
| "switch"
| "number"
| "checkbox"
| "select"
| "multiselect"
| "password"
| "date"
| "datetime"
| "dateinput"
| "time"
| "radio"
| "file"
| "color"
| "slider"
| "segmented"
| "native-select"
| "json"
| "rating"
| "autocomplete"
| "richtext";
interface BaseFormField<T> {
name: keyof T;
label: string;
description?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
hidden?: boolean;
className?: string;
wrapperProps?: Record<string, unknown>;
data?: any;
clearable?: any;
}
interface RichTextFormField<T> extends BaseFormField<T> {
type: "richtext";
minHeight?: number;
withTypography?: boolean;
withLinks?: boolean;
withImages?: boolean;
withTextAlignment?: boolean;
withColor?: boolean;
withHighlight?: boolean;
}
type SpecificFormField<T> =
| (BaseFormField<T> & { type: "text" })
| (BaseFormField<T> & { type: "textarea" })
| (BaseFormField<T> & { type: "switch" })
| (BaseFormField<T> & { type: "number" })
| (BaseFormField<T> & { type: "checkbox" })
| (BaseFormField<T> & { type: "select" })
| (BaseFormField<T> & { type: "multiselect" })
| (BaseFormField<T> & { type: "password" })
| (BaseFormField<T> & { type: "date" })
| (BaseFormField<T> & { type: "datetime" })
| (BaseFormField<T> & { type: "dateinput" })
| (BaseFormField<T> & { type: "time" })
| (BaseFormField<T> & { type: "radio" })
| (BaseFormField<T> & { type: "file" })
| (BaseFormField<T> & { type: "color" })
| (BaseFormField<T> & { type: "slider" })
| (BaseFormField<T> & { type: "segmented" })
| (BaseFormField<T> & { type: "native-select" })
| (BaseFormField<T> & { type: "json" })
| (BaseFormField<T> & { type: "rating" })
| (RichTextFormField<T> & { type: "richtext" })
| (BaseFormField<T> & { type: "autocomplete" });
export type FormField<T> = SpecificFormField<T>;
// Updated GenericFormProps to work with our shadcn/Tailwind useGenericForm
export interface GenericFormProps<T> {
form: {
values: T;
setValues: (values: Partial<T>) => void;
errors: Record<string, string>;
setError: (field: keyof T, message: string) => void;
validate: () => boolean;
reset: () => void;
fields: FormField<T>[];
};
fields: FormField<T>[];
onSubmit: (values: T) => Promise<void> | void;
onCancel?: () => void;
isLoading?: boolean;
isSubmitting?: boolean;
submitButtonText?: string;
cancelButtonText?: string;
successMessage?: string;
errorMessage?: string;
customButtons?: React.ReactNode;
resetOnSuccess?: boolean;
}

View File

@@ -0,0 +1,462 @@
import { useState, useEffect } from "react";
import { FieldValues } from "react-hook-form";
import { motion, Variants } from "framer-motion";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/shared/common/ui/card";
import { Badge } from "@/shared/common/ui/badge";
import { useToast } from "@/shared/common/ui/use-toast";
import { Loader2, Check, ArrowLeft, Shield, Edit } from "lucide-react";
import { FormField, GenericFormProps } from "./shared-field-types";
import { FormFieldRenderer } from "./FormFieldRenderer";
import { cn } from "@/shared/lib/utils";
import { useInfiniteQuery } from "@tanstack/react-query";
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08,
duration: 0.6,
},
},
};
const sectionVariants: Variants = {
hidden: {
opacity: 0,
y: 20,
scale: 0.98,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
type: "spring" as const,
stiffness: 100,
damping: 15,
duration: 0.5,
},
},
};
const buttonVariants: Variants = {
hidden: { opacity: 0, x: 20 },
visible: {
opacity: 1,
x: 0,
transition: {
type: "spring",
stiffness: 200,
damping: 20,
},
},
hover: {
scale: 1.02,
transition: {
type: "spring",
stiffness: 400,
damping: 10,
},
},
tap: {
scale: 0.98,
},
};
// Section icons mapping
const sectionIcons: Record<string, React.ElementType> = {
"Basic Information": Shield,
Organization: Shield,
"Plan Details": Edit,
default: Edit,
};
// Section color themes
const sectionThemes: Record<
string,
{ gradient: string; border: string; bg: string }
> = {
"Basic Information": {
gradient: "from-green-500 to-emerald-500",
border: "border-green-200/50",
bg: "bg-green-50/30",
},
Organization: {
gradient: "from-purple-500 to-pink-500",
border: "border-purple-200/50",
bg: "bg-purple-50/30",
},
"Plan Details": {
gradient: "from-emerald-500 to-teal-500",
border: "border-emerald-200/50",
bg: "bg-emerald-50/30",
},
default: {
gradient: "from-gray-500 to-slate-500",
border: "border-gray-200/50",
bg: "bg-gray-50/30",
},
};
// Get grid column class based on field type and context
const getGridClasses = (field: FormField<any>) => {
// For textarea fields, default to full width
if (field.type === "textarea") {
return "col-span-full";
}
// Determine column spans with defaults
const colSpan = field.colSpan || 12;
// Map to actual Tailwind classes based on your grid system
const spanMap: Record<number, string> = {
1: "col-span-1",
2: "col-span-2",
3: "col-span-3",
4: "col-span-4",
5: "col-span-5",
6: "col-span-6",
7: "col-span-7",
8: "col-span-8",
9: "col-span-9",
10: "col-span-10",
11: "col-span-11",
12: "col-span-12",
};
return spanMap[colSpan] || "col-span-12";
};
export function GenericForm<T extends FieldValues>({
form,
fields,
onSubmit,
onCancel,
isLoading = false,
isSubmitting: externalIsSubmitting,
submitButtonText = "Submit",
cancelButtonText = "Cancel",
successMessage = "Operation completed successfully",
errorMessage = "An error occurred",
customButtons,
resetOnSuccess = false,
defaultValue,
title,
description,
showProgress = true,
}: GenericFormProps<T> & {
title?: string;
description?: string;
showProgress?: boolean;
}) {
const [internalIsSubmitting, setInternalIsSubmitting] = useState(false);
const [progress, setProgress] = useState(0);
const isSubmitting = externalIsSubmitting ?? internalIsSubmitting;
const { toast } = useToast();
const [shouldClearEditor, setShouldClearEditor] = useState(false);
// Calculate form completion progress
const calculateProgress = () => {
const totalFields = fields.filter((field) => !field.hidden).length;
const filledFields = fields.filter((field) => {
if (field.hidden) return false;
const value = form.getValues(field.name as any);
return value !== undefined && value !== null && value !== "";
}).length;
return Math.round((filledFields / totalFields) * 100);
};
// Update progress on form changes
useEffect(() => {
const subscription = form.watch(() => {
setProgress(calculateProgress());
});
return () => subscription.unsubscribe();
}, [form, fields]);
const handleSubmit = async (values: T) => {
try {
setInternalIsSubmitting(true);
await onSubmit(values);
if (resetOnSuccess) {
form.reset();
setProgress(0);
}
setShouldClearEditor(true);
} catch (error: unknown) {
console.error("Error submitting form:", error);
} finally {
setInternalIsSubmitting(false);
}
};
const renderFormField = (
field: FormField<T>,
index: number,
fieldsInSection: FormField<T>[]
) => {
const gridClass = getGridClasses(field);
return (
<motion.div
key={field.name as string}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className={gridClass}>
<FormFieldRenderer
field={field}
form={form}
isLoading={isLoading}
isSubmitting={isSubmitting}
defaultValue={defaultValue}
shouldClearEditor={shouldClearEditor}
onScroll={field.onScroll}
/>
</motion.div>
);
};
// Group fields by section
const sections = fields.reduce((acc, field) => {
const section = field.section || "default";
if (!acc[section]) {
acc[section] = [];
}
acc[section].push(field);
return acc;
}, {} as Record<string, FormField<T>[]>);
const hasMultipleSections = Object.keys(sections).length > 1;
const SectionIcon = ({ sectionName }: { sectionName: string }) => {
const IconComponent = sectionIcons[sectionName] || sectionIcons.default;
return <IconComponent className="h-4 w-4" />;
};
const renderActionButtons = (className?: string) => (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className={cn(
"flex flex-col sm:flex-row justify-between items-center gap-4",
className
)}>
<div className="flex items-center space-x-3 text-sm text-muted-foreground"></div>
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
{customButtons}
{onCancel && (
<motion.div variants={buttonVariants}>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading || isSubmitting}
className="min-w-[120px] border-2 hover:border-gray-300 hover:bg-gray-50/80 transition-all duration-200 group">
<ArrowLeft className="h-4 w-4 mr-2 group-hover:-translate-x-1 transition-transform" />
{cancelButtonText}
</Button>
</motion.div>
)}
<motion.div variants={buttonVariants} whileHover="hover" whileTap="tap">
<Button
type="submit"
disabled={isLoading || isSubmitting}
className="min-w-[140px] bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white shadow-lg shadow-green-500/25 hover:shadow-xl hover:shadow-green-500/30 transition-all duration-300">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
{submitButtonText}
</>
)}
</Button>
</motion.div>
</div>
</motion.div>
);
return (
<div className="w-full max-w-7xl mx-auto space-y-8">
{/* Header Section */}
{(title || description) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center space-y-4">
{title && (
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-gray-700 bg-clip-text text-transparent">
{title}
</h1>
)}
{description && (
<p className="text-lg text-muted-foreground max-w-2xl mx-auto leading-relaxed">
{description}
</p>
)}
</motion.div>
)}
{/* Progress Bar */}
{showProgress && (
<motion.div
initial={{ opacity: 0, scaleX: 0 }}
animate={{ opacity: 1, scaleX: 1 }}
className="space-y-2">
<div className="flex justify-between items-center text-sm text-muted-foreground">
<span>Form Progress</span>
<span className="font-semibold text-primary">{progress}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-green-500 to-emerald-500 rounded-full"
initial={{ width: "0%" }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.8, ease: "easeOut" }}
/>
</div>
</motion.div>
)}
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-8">
{/* Loading Overlay */}
{(isLoading || isSubmitting) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-background/80 backdrop-blur-lg flex items-center justify-center z-50">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white/95 p-8 rounded-3xl shadow-2xl border border-gray-200/50 flex flex-col items-center gap-6 max-w-sm w-full mx-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-16 h-16 bg-gradient-to-r from-green-500 to-emerald-500 rounded-full flex items-center justify-center">
<Loader2 className="h-8 w-8 text-white" />
</motion.div>
<div className="text-center space-y-2">
<h3 className="font-semibold text-lg text-gray-900">
Processing...
</h3>
<p className="text-sm text-gray-600">
Please wait while we save your changes
</p>
</div>
</motion.div>
</motion.div>
)}
{/* Form Sections */}
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-8">
{hasMultipleSections ? (
Object.entries(sections).map(([sectionName, sectionFields]) => {
const theme = sectionThemes[sectionName] || sectionThemes.default;
const visibleFields = sectionFields.filter(
(field) => !field.hidden
);
if (visibleFields.length === 0) return null;
return (
<motion.div
key={sectionName}
variants={sectionVariants}
whileHover={{ y: -2 }}
className="group">
<Card
className={`border-0 shadow-lg overflow-hidden backdrop-blur-sm bg-white/70 ${theme.bg} transition-all duration-300 group-hover:shadow-xl`}>
{sectionName !== "default" && (
<CardHeader
className={`pb-4 bg-gradient-to-r ${theme.gradient} text-white`}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
<SectionIcon sectionName={sectionName} />
</div>
<div>
<CardTitle className="text-xl font-bold text-white">
{sectionName}
</CardTitle>
<CardDescription className="text-white/80">
{visibleFields.length} field
{visibleFields.length !== 1 ? "s" : ""} to
complete
</CardDescription>
</div>
</div>
<Badge
variant="secondary"
className="bg-white/20 text-white border-white/30">
{sectionName === "default"
? "General"
: sectionName}
</Badge>
</div>
</CardHeader>
)}
<CardContent className="p-6">
<div className="grid grid-cols-12 gap-4">
{visibleFields.map((field, index) =>
renderFormField(field, index, visibleFields)
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})
) : (
<motion.div variants={sectionVariants}>
<Card className="border-0 shadow-xl bg-white/80 backdrop-blur-sm overflow-hidden ring-1 ring-black/5">
<CardContent className="p-8">
<div className="grid grid-cols-12 gap-4">
{fields
.filter((field) => !field.hidden)
.map((field, index) =>
renderFormField(
field,
index,
fields.filter((f) => !f.hidden)
)
)}
</div>
{/* Action Buttons Inside Card for Single Section */}
{renderActionButtons("pt-8 mt-8 border-t border-gray-100")}
</CardContent>
</Card>
</motion.div>
)}
</motion.div>
{/* Action Buttons Outside for Multiple Sections */}
{hasMultipleSections &&
renderActionButtons("pt-8 border-t border-gray-200/50")}
</form>
</div>
);
}

View File

@@ -0,0 +1,98 @@
// components/ui/multi-select.tsx
import * as React from "react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/shared/common/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { Badge } from "@/shared/common/ui/badge";
import { cn } from "@/shared/lib/utils";
interface MultiSelectProps {
options: { value: string; label: string }[];
value: string[];
onValueChange: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
}
export function MultiSelect({
options,
value,
onValueChange,
placeholder = "Select options",
disabled = false,
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const handleSelect = (optionValue: string) => {
onValueChange(
value.includes(optionValue)
? value.filter((v) => v !== optionValue)
: [...value, optionValue]
);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}>
<div className="flex flex-wrap gap-1">
{value.length === 0 && <span>{placeholder}</span>}
{value.map((val) => {
const option = options.find((o) => o.value === val);
return (
<Badge key={val} variant="secondary" className="mr-1">
{option?.label}
<X
className="ml-1 h-3 w-3 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleSelect(val);
}}
/>
</Badge>
);
})}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search options..." />
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => handleSelect(option.value)}>
<Check
className={cn(
"mr-2 h-4 w-4",
value.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,70 @@
import React from "react";
type NavItemProps = {
to?: string;
children?: React.ReactNode;
[key: string]: any;
};
/**
* Filters navigation items based on user's roles & dynamic permission config.
*/
export const filterNavLinksByRole = (
navItems: React.ReactNode,
userRoles?: string[],
permissions?: Record<string, string[]>
): React.ReactNode => {
if (!userRoles || userRoles.length === 0) return null;
if (userRoles.includes("super_admin")) return navItems;
const filteredItems = React.Children.map(navItems, (item) => {
if (!React.isValidElement<NavItemProps>(item)) return null;
const props = item.props as NavItemProps;
const path = props.to;
const children = props.children;
if (children) {
const filteredChildren = React.Children.map(children, (child) => {
if (!React.isValidElement<NavItemProps>(child)) return null;
const childProps = child.props as NavItemProps;
const childPath = childProps.to;
return childPath && shouldShowItem(childPath, userRoles, permissions)
? child
: null;
})?.filter(Boolean);
if (filteredChildren?.length) {
// Cast item as ReactElement<NavItemProps> to allow children
return React.cloneElement<NavItemProps>(item, {
...props,
children: filteredChildren,
});
}
return path && shouldShowItem(path, userRoles, permissions) ? item : null;
}
return path && shouldShowItem(path, userRoles, permissions) ? item : null;
})?.filter(Boolean);
return filteredItems ?? null;
};
const shouldShowItem = (
path: string,
userRoles?: string[],
permissions?: Record<string, string[]>
): boolean => {
if (!userRoles || userRoles.length === 0) return false;
if (!permissions) return false;
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
if (!permissions[cleanPath]) return false;
return userRoles.some((role) => permissions[cleanPath].includes(role));
};

View File

@@ -0,0 +1,83 @@
import i18n from "@/i18n";
enum PlanType {
NUMBER = "number",
PERCENTAGE = "percent",
BOOLEAN = "boolean",
}
export const getPlanTypeOptions = () => [
{ label: i18n.t("plans.Number"), value: "number" },
{ label: i18n.t("plans.Percent"), value: "percent" },
]
// Quarter list with translation keys
export const QUARTER_KEYS = [
{ key: "plans.quarters.q1", value: "1" },
{ key: "plans.quarters.q2", value: "2" },
{ key: "plans.quarters.q3", value: "3" },
{ key: "plans.quarters.q4", value: "4" },
];
// Month list with translation keys
export const MONTH_KEYS = [
{ key: "plans.months.july", value: "1" },
{ key: "plans.months.august", value: "2" },
{ key: "plans.months.september", value: "3" },
{ key: "plans.months.october", value: "4" },
{ key: "plans.months.november", value: "5" },
{ key: "plans.months.december", value: "6" },
{ key: "plans.months.january", value: "7" },
{ key: "plans.months.february", value: "8" },
{ key: "plans.months.march", value: "9" },
{ key: "plans.months.april", value: "10" },
{ key: "plans.months.may", value: "11" },
{ key: "plans.months.june", value: "12" },
];
// Week list with translation keys
export const WEEK_KEYS = [
{ key: "plans.weeks.week1", value: "1" },
{ key: "plans.weeks.week2", value: "2" },
{ key: "plans.weeks.week3", value: "3" },
{ key: "plans.weeks.week4", value: "4" },
];
// Day list with translation keys
export const DAY_KEYS = [
{ key: "plans.days.monday", value: "1" },
{ key: "plans.days.tuesday", value: "2" },
{ key: "plans.days.wednesday", value: "3" },
{ key: "plans.days.thursday", value: "4" },
{ key: "plans.days.friday", value: "5" },
{ key: "plans.days.saturday", value: "6" },
{ key: "plans.days.sunday", value: "7" },
];
export const getQuarterList = (t?: (key: string) => string) => {
const translator = t || ((key: string) => i18n.t(key));
return QUARTER_KEYS.map(item => ({ label: translator(item.key), value: item.value }));
};
export const getMonthList = (t?: (key: string) => string) => {
const translator = t || ((key: string) => i18n.t(key));
return MONTH_KEYS.map(item => ({ label: translator(item.key), value: item.value }));
};
export const getWeekList = (t?: (key: string) => string) => {
const translator = t || ((key: string) => i18n.t(key));
return WEEK_KEYS.map(item => ({ label: translator(item.key), value: item.value }));
};
export const getDayList = (t?: (key: string) => string) => {
const translator = t || ((key: string) => i18n.t(key));
return DAY_KEYS.map(item => ({ label: translator(item.key), value: item.value }));
};
// Deprecated: Use getQuarterList(), getMonthList(), getWeekList(), getDayList() instead
export const QuarterList = getQuarterList();
export const MonthList = getMonthList();
export const WeekList = getWeekList();
export const DayList = getDayList();
export default PlanType;

View File

@@ -0,0 +1,154 @@
import {
MeDto,
EPositionTypeKey,
POSITION_TYPE_ALIASES,
} from "@/shared/dto/user/meDto";
/** Build match candidates from backend keys like performance_director or org/director */
const positionTypeCandidates = (rawKey: string): string[] => {
const candidates = new Set<string>([rawKey]);
const slashIdx = rawKey.lastIndexOf("/");
if (slashIdx >= 0) {
candidates.add(rawKey.slice(slashIdx + 1));
}
const underscoreIdx = rawKey.lastIndexOf("_");
if (underscoreIdx >= 0) {
candidates.add(rawKey.slice(underscoreIdx + 1));
}
if (rawKey.toLowerCase().startsWith("performance_")) {
candidates.add(rawKey.slice("performance_".length));
}
// performance_teamleader → teamleader → team-leader
if (rawKey.toLowerCase().includes("teamleader")) {
candidates.add("teamleader");
candidates.add("team-leader");
}
return [...candidates];
};
/**
* Returns true when a backend key matches the canonical position type.
* Checks positionType.key (e.g. performance_director) and position.key (e.g. org/director).
*/
export const matchesPositionType = (
rawKey: string | undefined | null,
level: EPositionTypeKey,
): boolean => {
if (!rawKey) return false;
const aliases = POSITION_TYPE_ALIASES[level] ?? [level];
return positionTypeCandidates(rawKey).some((candidate) => aliases.includes(candidate));
};
/** Resolve positionType.key or position.key to canonical EPositionTypeKey. */
export const resolvePositionTypeKey = (
rawKey: string | undefined | null,
): EPositionTypeKey | null => {
if (!rawKey) return null;
for (const level of Object.values(EPositionTypeKey)) {
if (matchesPositionType(rawKey, level as EPositionTypeKey)) {
return level as EPositionTypeKey;
}
}
return null;
};
type PositionLike = {
key?: string;
positionType?: { key?: string } | null;
};
export const resolvePositionType = (
position: PositionLike | undefined | null,
): EPositionTypeKey | null => {
if (!position) return null;
return (
resolvePositionTypeKey(position.positionType?.key) ??
resolvePositionTypeKey(position.key) ??
null
);
};
/**
* Get user's highest position type based on hierarchy priority
* Priority order: DEPUTY > DIRECTOR > BUDGET_PLAN > OFFICE_HEAD > TEAM_LEADER > EMPLOYEE
*/
export const getUserPositionType = (
user: MeDto | null,
): EPositionTypeKey | null => {
if (!user?.employee?.length) return null;
const positions = user.employee[0]?.positions || [];
if (!positions.length) return null;
const positionHierarchy = [
EPositionTypeKey.DEPUTY,
EPositionTypeKey.DIRECTOR,
EPositionTypeKey.BUDGET_PLAN,
EPositionTypeKey.OFFICE_HEAD,
EPositionTypeKey.TEAM_LEADER,
EPositionTypeKey.EMPLOYEE,
];
for (const level of positionHierarchy) {
const hasPosition = positions.some((p) => resolvePositionType(p) === level);
if (hasPosition) return level;
}
return EPositionTypeKey.EMPLOYEE;
};
/**
* Position type for the currently selected position (employeePositionId or position id).
*/
export const getSelectedPositionType = (
user: MeDto | null,
selectedPositionId: string | null | undefined,
): EPositionTypeKey | null => {
if (!user?.employee?.length) return null;
const allPositions = user.employee.flatMap((emp) => emp.positions ?? []);
if (selectedPositionId) {
const active = allPositions.find(
(p) =>
p.employeePositionId === selectedPositionId || p.id === selectedPositionId,
);
const resolved = resolvePositionType(active);
if (resolved) return resolved;
}
return getUserPositionType(user);
};
export const hasPositionType = (
user: MeDto | null,
type: EPositionTypeKey,
): boolean => {
if (!user?.employee?.length) return false;
const positions = user.employee[0]?.positions || [];
return positions.some((p) => resolvePositionType(p) === type);
};
export const getUserOrgContext = (user: MeDto | null) => {
if (!user?.employee?.length) return null;
const employee = user.employee[0];
const position = employee?.positions?.[0];
return {
organizationId: employee?.organizationId,
unitId: employee?.unitId,
employeeId: employee?.id,
positionId: position?.id,
};
};
export const getUserPositionId = (user: MeDto | null): string | null => {
if (!user?.employee?.length) return null;
return user.employee[0]?.positions?.[0]?.id || null;
};

View File

@@ -0,0 +1,265 @@
// shared-field-types.ts
import { LucideIcon } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { FieldValues } from "react-hook-form";
export type FormFieldType =
| "text"
| "textarea"
| "switch"
| "number"
| "checkbox"
| "select"
| "multiselect"
| "password"
| "date"
| "datetime"
| "dateinput"
| "time"
| "radio"
| "file"
| "color"
| "slider"
| "segmented"
| "native-select"
| "json"
| "rating"
| "autocomplete"
| "richtext"
| "habesha-date"
| "year-picker";
export interface BaseFormField<T> {
name: keyof T;
label: string;
description?: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
hidden?: boolean;
className?: string;
wrapperProps?: Record<string, unknown>;
data?: any;
clearable?: any;
section?: string;
icon?: React.ComponentType<{ className?: string }>;
group?: string;
defaultValue?: any;
// Grid span control (1-12)
colSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
// For responsive design
smColSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
lgColSpan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
// Existing properties...
options?: string[] | { value: string; label: string; icon?: LucideIcon }[];
onChange?: (value: any) => void;
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
}
// Define specific field interfaces for better type safety
export interface TextFormField<T> extends BaseFormField<T> {
type: "text";
autoComplete?: string;
}
export interface TextareaFormField<T> extends BaseFormField<T> {
type: "textarea";
rows?: number;
}
export interface NumberFormField<T> extends BaseFormField<T> {
type: "number";
min?: number;
max?: number;
step?: number | "any";
}
export interface PasswordFormField<T> extends BaseFormField<T> {
type: "password";
autoComplete?: "current-password" | "new-password";
}
export interface SwitchFormField<T> extends BaseFormField<T> {
type: "switch";
}
export interface CheckboxFormField<T> extends BaseFormField<T> {
type: "checkbox";
}
export interface SelectFormField<T> extends BaseFormField<T> {
type: "select";
options: { value: string; label: string; disabled?: boolean }[];
searchable?: boolean;
clearable?: boolean;
defaultValue?: string;
}
export interface MultiSelectFormField<T> extends BaseFormField<T> {
type: "multiselect";
options: { value: string; label: string; disabled?: boolean }[];
searchable?: boolean;
defaultValue?: string[];
}
export interface DateFormField<T> extends BaseFormField<T> {
type: "date" | "datetime" | "dateinput" | "time";
minDate?: Date;
maxDate?: Date;
defaultValue?: string;
}
export interface RadioFormField<T> extends BaseFormField<T> {
type: "radio";
options: { value: string; label: string }[];
orientation?: "horizontal" | "vertical";
}
export interface FileFormField<T> extends BaseFormField<T> {
type: "file";
accept?: string;
multiple?: boolean;
}
export interface ColorFormField<T> extends BaseFormField<T> {
type: "color";
}
export interface SliderFormField<T> extends BaseFormField<T> {
type: "slider";
min?: number;
max?: number;
step?: number;
marks?: { value: number; label: string }[];
}
export interface SegmentedFormField<T> extends BaseFormField<T> {
type: "segmented";
options: { value: string; label: string }[];
}
export interface RatingFormField<T> extends BaseFormField<T> {
type: "rating";
count?: number;
size?: "sm" | "md" | "lg";
showLabels?: boolean;
labels?: string[];
language?: "am" | "en";
}
export interface HabeshaDateFormField<T> extends BaseFormField<T> {
type: "habesha-date";
language?: "am" | "en";
}
export interface YearPickerFormField<T> extends BaseFormField<T> {
type: "year-picker";
calendarType?: "gregorian" | "habesha";
language?: "am" | "en";
minYear?: number;
maxYear?: number;
}
export interface AutocompleteFormField<T> extends BaseFormField<T> {
type: "autocomplete";
options: string[] | { value: string; label: string }[];
}
export interface RichTextFormField<T> extends BaseFormField<T> {
type: "richtext";
minHeight?: number;
withTypography?: boolean;
withLinks?: boolean;
withImages?: boolean;
withTextAlignment?: boolean;
withColor?: boolean;
withHighlight?: boolean;
}
// Union type for all form fields
export type FormField<T> =
| TextFormField<T>
| TextareaFormField<T>
| NumberFormField<T>
| PasswordFormField<T>
| SwitchFormField<T>
| CheckboxFormField<T>
| SelectFormField<T>
| MultiSelectFormField<T>
| DateFormField<T>
| RadioFormField<T>
| FileFormField<T>
| ColorFormField<T>
| SliderFormField<T>
| SegmentedFormField<T>
| RatingFormField<T>
| AutocompleteFormField<T>
| RichTextFormField<T>
| HabeshaDateFormField<T>
| YearPickerFormField<T>;
// Type guards for better type checking
export function isSelectField<T>(
field: FormField<T>
): field is SelectFormField<T> {
return field.type === "select";
}
export function isMultiSelectField<T>(
field: FormField<T>
): field is MultiSelectFormField<T> {
return field.type === "multiselect";
}
export function isRadioField<T>(
field: FormField<T>
): field is RadioFormField<T> {
return field.type === "radio";
}
export function isAutocompleteField<T>(
field: FormField<T>
): field is AutocompleteFormField<T> {
return field.type === "autocomplete";
}
export function hasOptions<T>(
field: FormField<T>
): field is
| SelectFormField<T>
| MultiSelectFormField<T>
| RadioFormField<T>
| AutocompleteFormField<T> {
return (
field.type === "select" ||
field.type === "multiselect" ||
field.type === "radio" ||
field.type === "autocomplete"
);
}
// GenericFormProps using react-hook-form
export interface GenericFormProps<T extends FieldValues> {
form: UseFormReturn<T>;
fields: FormField<T>[];
onSubmit: (values: T) => Promise<void> | void;
onCancel?: () => void;
isLoading?: boolean;
isSubmitting?: boolean;
submitButtonText?: string;
cancelButtonText?: string;
successMessage?: string;
errorMessage?: string;
customButtons?: React.ReactNode;
resetOnSuccess?: boolean;
defaultValue?: string;
}
export enum TimeFrame {
MONTH = "monthly",
WEEK = "weekly",
DAY = "daily",
QUARTER = "quarterly",
YEAR = "yearly",
}

View File

@@ -0,0 +1,91 @@
/**
* Utility functions for generating and handling URL slugs
*/
/**
* Generate a URL-friendly slug from a localized name object
* Uses English name, falls back to Amharic if English is not available
*/
export const generateSlugFromLocalizedName = (name: {
en?: string;
am?: string;
}): string => {
const text = name.en || name.am || "";
return generateSlug(text);
};
/**
* Generate a URL-friendly slug from text
* - Converts to lowercase
* - Replaces spaces with hyphens
* - Removes special characters
* - Handles multiple consecutive hyphens
*/
export const generateSlug = (text: string): string => {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "") // Remove special characters
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
};
/**
* Create a unique slug by appending ID suffix
* Format: "plan-name-abc123"
*/
export const createUniqueSlug = (text: string, id: string): string => {
const baseSlug = generateSlug(text);
const shortId = id.slice(0, 8); // Use first 8 chars of UUID
return `${baseSlug}-${shortId}`;
};
/**
* Extract ID from a unique slug
* Format: "plan-name-abc123" -> "abc123"
*/
export const extractIdFromSlug = (slug: string): string => {
const parts = slug.split("-");
return parts[parts.length - 1]; // Return last part (the ID)
};
/**
* Check if a string is a UUID
*/
export const isUUID = (str: string): boolean => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
/**
* Parse year from URL parameter
* Handles both year numbers (2024) and UUIDs (for backward compatibility)
*/
export const parseYearParam = (yearParam: string): {
isYear: boolean;
value: string;
} => {
// Check if it's a 4-digit year
if (/^\d{4}$/.test(yearParam)) {
return { isYear: true, value: yearParam };
}
// Otherwise treat as UUID
return { isYear: false, value: yearParam };
};
/**
* Parse slug parameter
* Handles both slugs and UUIDs (for backward compatibility)
*/
export const parseSlugParam = (
slugParam: string
): { isSlug: boolean; id: string } => {
if (isUUID(slugParam)) {
// It's a UUID, use directly
return { isSlug: false, id: slugParam };
}
// It's a slug, extract ID
return { isSlug: true, id: extractIdFromSlug(slugParam) };
};

View File

@@ -0,0 +1,52 @@
// utils/statusBadge.tsx
interface StatusBadgeProps {
status: string;
className?: string;
}
export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
const getStatusConfig = (status: string) => {
const config = {
waiting: {
gradient: "from-yellow-500 to-amber-500",
glow: "shadow-lg shadow-yellow-500/20",
icon: "⏳",
},
in_progress: {
gradient: "from-blue-500 to-cyan-500",
glow: "shadow-lg shadow-blue-500/20",
icon: "⚡",
},
completed: {
gradient: "from-green-500 to-emerald-500",
glow: "shadow-lg shadow-green-500/20",
icon: "✅",
},
cancelled: {
gradient: "from-red-500 to-pink-500",
glow: "shadow-lg shadow-red-500/20",
icon: "❌",
},
};
const defaultConfig = {
gradient: "from-gray-500 to-slate-500",
glow: "shadow-lg shadow-gray-500/20",
icon: "📋",
};
return config[status as keyof typeof config] || defaultConfig;
};
const { gradient, glow, icon } = getStatusConfig(status);
const label =
status.charAt(0).toUpperCase() + status.slice(1).replace("_", " ");
return (
<div
className={`inline-flex items-center px-4 py-2 rounded-2xl bg-gradient-to-r ${gradient} ${glow} border border-white/20 backdrop-blur-sm ${className}`}>
<span className="text-sm mr-2">{icon}</span>
<span className="text-white font-semibold text-sm">{label}</span>
</div>
);
}