mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 07:15:45 +00:00
fix ui
This commit is contained in:
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() };
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
enum DateMeasurementUnit {
|
||||
MINUTES = "minute",
|
||||
HOURS = "hour",
|
||||
DAY = "day",
|
||||
WEEKS = "weeks",
|
||||
YEAR = "year",
|
||||
MONTH = "month",
|
||||
SECOND = "second",
|
||||
}
|
||||
|
||||
export default DateMeasurementUnit;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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})`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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) };
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user