229 lines
8.0 KiB
TypeScript
229 lines
8.0 KiB
TypeScript
import type {
|
|
AIInsight,
|
|
ChickenCounting,
|
|
ChickenWeight,
|
|
CountingWrite,
|
|
Cycle,
|
|
CycleFilter,
|
|
CycleWrite,
|
|
Flock,
|
|
FlockWrite,
|
|
InitialBalanceResult,
|
|
Karung,
|
|
KarungRequestResult,
|
|
Kandang,
|
|
KandangWrite,
|
|
KPI,
|
|
LatestAvg,
|
|
ManualInput,
|
|
ManualInputWrite,
|
|
Paginated,
|
|
PanelIoT,
|
|
Site,
|
|
SiteWrite,
|
|
User,
|
|
} from '../types/api.ts';
|
|
|
|
const API_BASE = (import.meta.env.VITE_API_BASE || '/api/v1').replace(/\/$/, '');
|
|
|
|
export const AUTH_UNAUTHORIZED_EVENT = 'dashboard-auth-unauthorized';
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
body: unknown;
|
|
|
|
constructor(message: string, status: number, body: unknown) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
export function errorMessage(err: unknown): string {
|
|
if (err instanceof ApiError) return err.message;
|
|
if (err instanceof Error) return err.message;
|
|
return 'Terjadi kesalahan';
|
|
}
|
|
|
|
function getCookie(name: string): string | null {
|
|
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
|
const match = document.cookie.match(new RegExp(`(?:^|; )${escaped}=([^;]*)`));
|
|
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
}
|
|
|
|
function parseDetail(body: unknown): string {
|
|
if (!body || typeof body !== 'object') return 'Request failed';
|
|
const detail = (body as { detail?: unknown }).detail;
|
|
if (typeof detail === 'string') return detail;
|
|
if (Array.isArray(detail)) return detail.map(String).join(', ');
|
|
const entries = Object.entries(body as Record<string, unknown>)
|
|
.filter(([key]) => key !== 'detail')
|
|
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : String(value)}`);
|
|
return entries.length > 0 ? entries.join('; ') : 'Request failed';
|
|
}
|
|
|
|
async function ensureCsrf(): Promise<string> {
|
|
const existing = getCookie('csrftoken');
|
|
if (existing) return existing;
|
|
const res = await fetch(`${API_BASE}/auth/csrf/`, { credentials: 'include' });
|
|
if (!res.ok) {
|
|
throw new ApiError('Gagal mengambil CSRF token', res.status, null);
|
|
}
|
|
const data = (await res.json()) as { csrfToken?: string };
|
|
return data.csrfToken || getCookie('csrftoken') || '';
|
|
}
|
|
|
|
type Query = Record<string, string | number | boolean | null | undefined>;
|
|
|
|
function withQuery(path: string, query?: Query): string {
|
|
if (!query) return path;
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value === undefined || value === null || value === '') continue;
|
|
params.set(key, String(value));
|
|
}
|
|
const qs = params.toString();
|
|
return qs ? `${path}?${qs}` : path;
|
|
}
|
|
|
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const method = (init.method || 'GET').toUpperCase();
|
|
const headers = new Headers(init.headers);
|
|
headers.set('Accept', 'application/json');
|
|
if (init.body && !headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
const csrf = await ensureCsrf();
|
|
if (csrf) headers.set('X-CSRFToken', csrf);
|
|
}
|
|
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
...init,
|
|
method,
|
|
headers,
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
const text = await res.text();
|
|
let body: unknown = null;
|
|
if (text) {
|
|
try {
|
|
body = JSON.parse(text) as unknown;
|
|
} catch {
|
|
body = text;
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401 || res.status === 403) {
|
|
window.dispatchEvent(new CustomEvent(AUTH_UNAUTHORIZED_EVENT));
|
|
}
|
|
throw new ApiError(parseDetail(body), res.status, body);
|
|
}
|
|
return body as T;
|
|
}
|
|
|
|
function unwrapList<T>(data: Paginated<T> | T[]): T[] {
|
|
if (Array.isArray(data)) return data;
|
|
return data.results ?? [];
|
|
}
|
|
|
|
const get = <T>(path: string, query?: Query) => request<T>(withQuery(path, query));
|
|
const post = <T>(path: string, body?: unknown) =>
|
|
request<T>(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined });
|
|
const patch = <T>(path: string, body: unknown) =>
|
|
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) });
|
|
const del = (path: string) => request<void>(path, { method: 'DELETE' });
|
|
|
|
export const api = {
|
|
auth: {
|
|
csrf: () => get<{ csrfToken: string }>('/auth/csrf/'),
|
|
login: (user_name: string, password: string) =>
|
|
post<User>('/auth/login/', { user_name, password }),
|
|
logout: () => post<void>('/auth/logout/'),
|
|
me: () => get<User>('/auth/me/'),
|
|
},
|
|
sites: {
|
|
list: async (query?: { user_id?: number }) =>
|
|
unwrapList(await get<Paginated<Site> | Site[]>('/sites/', query)),
|
|
create: (body: SiteWrite) => post<Site>('/sites/', body),
|
|
update: (id: number, body: Partial<SiteWrite>) => patch<Site>(`/sites/${id}/`, body),
|
|
remove: (id: number) => del(`/sites/${id}/`),
|
|
},
|
|
kandangs: {
|
|
list: async (query?: { site_id?: number }) =>
|
|
unwrapList(await get<Paginated<Kandang> | Kandang[]>('/kandangs/', query)),
|
|
create: (body: KandangWrite) => post<Kandang>('/kandangs/', body),
|
|
update: (id: number, body: Partial<KandangWrite>) => patch<Kandang>(`/kandangs/${id}/`, body),
|
|
remove: (id: number) => del(`/kandangs/${id}/`),
|
|
},
|
|
flocks: {
|
|
list: async (query?: { kandang_id?: number }) =>
|
|
unwrapList(await get<Paginated<Flock> | Flock[]>('/flocks/', query)),
|
|
create: (body: FlockWrite) => post<Flock>('/flocks/', body),
|
|
update: (id: number, body: Partial<FlockWrite>) => patch<Flock>(`/flocks/${id}/`, body),
|
|
remove: (id: number) => del(`/flocks/${id}/`),
|
|
},
|
|
cycles: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<Cycle> | Cycle[]>('/cycles/', query)),
|
|
create: (body: CycleWrite) => post<Cycle>('/cycles/', body),
|
|
update: (id: number, body: Partial<CycleWrite>) => patch<Cycle>(`/cycles/${id}/`, body),
|
|
remove: (id: number) => del(`/cycles/${id}/`),
|
|
initialBalance: (id: number, body: { date: string; feed_in_manual: number }) =>
|
|
post<InitialBalanceResult>(`/cycles/${id}/initial-balance/`, body),
|
|
initialBalanceCompare: (
|
|
id: number,
|
|
query: { date: string; manual?: number }
|
|
) => get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
|
|
},
|
|
kpis: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<KPI> | KPI[]>('/kpis/', query)),
|
|
},
|
|
manualInputs: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<ManualInput> | ManualInput[]>('/manual-inputs/', query)),
|
|
create: (body: ManualInputWrite) => post<ManualInput>('/manual-inputs/', body),
|
|
update: (id: number, body: Partial<ManualInputWrite>) =>
|
|
patch<ManualInput>(`/manual-inputs/${id}/`, body),
|
|
},
|
|
countings: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(
|
|
await get<Paginated<ChickenCounting> | ChickenCounting[]>('/chicken-countings/', query)
|
|
),
|
|
create: (body: CountingWrite) => post<ChickenCounting>('/chicken-countings/', body),
|
|
update: (id: number, body: Partial<CountingWrite>) =>
|
|
patch<ChickenCounting>(`/chicken-countings/${id}/`, body),
|
|
remove: (id: number) => del(`/chicken-countings/${id}/`),
|
|
},
|
|
weights: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<ChickenWeight> | ChickenWeight[]>('/chicken-weights/', query)),
|
|
latestAvg: (query?: CycleFilter) => get<LatestAvg>('/chicken-weights/latest-avg/', query),
|
|
},
|
|
karungs: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<Karung> | Karung[]>('/karungs/', query)),
|
|
latest: (query?: CycleFilter) => get<Karung>('/karungs/latest/', query),
|
|
request: (body: { cycle_id: number; section?: 'today' | 'yesterday_history' }) =>
|
|
post<KarungRequestResult>('/karungs/request/', body),
|
|
},
|
|
panelIot: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<PanelIoT> | PanelIoT[]>('/panel-iot/', query)),
|
|
dates: async (query?: CycleFilter) => get<string[]>('/panel-iot/dates/', query),
|
|
},
|
|
insights: {
|
|
list: async (query?: CycleFilter) =>
|
|
unwrapList(await get<Paginated<AIInsight> | AIInsight[]>('/ai-insights/', query)),
|
|
},
|
|
};
|