feat(nav): promote Subscriptions to a top-level section with its own tabs
Subscriptions, Plans, and Tariff Lab were tabs under /setup. Moved them into a standalone /subscriptions section with its own header nav entry (between Turni and Konfigurimi) and a tab bar: Abonimet (/subscriptions), Planet (/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab). - New SubscriptionsLayout (tab bar + <Outlet>); the three screens are now its child routes at the top level, not under setupRoute. - Removed Subscriptions/Plans/Tariff-Lab from SetupLayout and SETUP_TABS. Setup now holds Devices/Tariff/Site/Users/Roles/Shifts/Logs. - Header gains the "Abonimet" link, gated on subscription:read OR subscription:plan OR tariff:read (shown if the user can reach any sub-tab). - Tabs are permission-gated; the /subscriptions index redirects a user lacking subscription:read to the first sub-tab they can see (or the booth). - Legacy redirects: /setup/subscriptions → /subscriptions, /setup/plans → /subscriptions/plans, /setup/tariff-lab → /subscriptions/tariff-lab. Dropped the old /subscriptions → /setup redirect (it's a real route now). - The Tariff COMPOSER stays in Setup; only the Tariff LAB simulator moved. Verified at runtime (Playwright): header order Kabina·Turni·Abonimet·Konfigurimi, the three sub-tabs render, Setup no longer lists them, /setup/subscriptions redirects cleanly. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+65
-24
@@ -82,9 +82,6 @@ function SetupLayout() {
|
|||||||
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
|
||||||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||||
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
|
|
||||||
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
|
||||||
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
|
|
||||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||||
@@ -96,6 +93,26 @@ function SetupLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
|
||||||
|
* with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
|
||||||
|
* tab is a gated child route; an operator with only subscription:read sees just the
|
||||||
|
* first tab. */
|
||||||
|
function SubscriptionsLayout() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const show = (perm: Permission) => can(user, perm);
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
|
||||||
|
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
|
||||||
|
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
|
||||||
|
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />}
|
||||||
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
/** SQ/EN toggle. Persists the choice to the user's profile (restored on next login)
|
||||||
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
* and applies it immediately. Updates the router-context user so App re-syncs. */
|
||||||
function LanguageToggle({
|
function LanguageToggle({
|
||||||
@@ -353,12 +370,16 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
<NavLink to="/booth" label={t("nav.booth")} />
|
<NavLink to="/booth" label={t("nav.booth")} />
|
||||||
<NavLink to="/shift" label={t("nav.shift")} />
|
<NavLink to="/shift" label={t("nav.shift")} />
|
||||||
{/* One Setup entry — its tabs hold devices/tariff/subscriptions/site/users/
|
{/* Subscriptions — a standalone section (Abonimet / Planet / Lab tarife).
|
||||||
roles/shifts. Shown if the user can reach ANY of those screens (an
|
Shown if the user can reach ANY of its tabs. */}
|
||||||
operator with only shift:read still gets in, landing on Shifts). */}
|
{(show("subscription:read") || show("subscription:plan") || show("tariff:read")) && (
|
||||||
|
<NavLink to="/subscriptions" label={t("nav.subscriptions")} />
|
||||||
|
)}
|
||||||
|
{/* One Setup entry — its tabs hold devices/tariff/site/users/roles/shifts/logs.
|
||||||
|
Shown if the user can reach ANY of those screens (an operator with only
|
||||||
|
shift:read still gets in, landing on Shifts). */}
|
||||||
{(show("site:update") ||
|
{(show("site:update") ||
|
||||||
show("tariff:read") ||
|
show("tariff:read") ||
|
||||||
show("subscription:read") ||
|
|
||||||
show("site:read") ||
|
show("site:read") ||
|
||||||
show("user:read") ||
|
show("user:read") ||
|
||||||
show("role:read") ||
|
show("role:read") ||
|
||||||
@@ -407,15 +428,19 @@ const boothRoute = createRoute({
|
|||||||
component: BoothScreen,
|
component: BoothScreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back-compat: the config screens used to be top-level routes. They now live under
|
// Back-compat redirects for paths that moved. Most config screens live under /setup;
|
||||||
// /setup as tabs — redirect the old paths so existing bookmarks/links don't 404.
|
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
|
||||||
|
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
|
||||||
|
// bookmarks/links don't 404. (No "/subscriptions" entry: that's now a REAL route.)
|
||||||
const legacyRedirects = (
|
const legacyRedirects = (
|
||||||
[
|
[
|
||||||
["/tariff", "/setup/tariff"],
|
["/tariff", "/setup/tariff"],
|
||||||
["/subscriptions", "/setup/subscriptions"],
|
|
||||||
["/site", "/setup/site"],
|
["/site", "/setup/site"],
|
||||||
["/users", "/setup/users"],
|
["/users", "/setup/users"],
|
||||||
["/roles", "/setup/roles"],
|
["/roles", "/setup/roles"],
|
||||||
|
["/setup/subscriptions", "/subscriptions"],
|
||||||
|
["/setup/plans", "/subscriptions/plans"],
|
||||||
|
["/setup/tariff-lab", "/subscriptions/tariff-lab"],
|
||||||
] as const
|
] as const
|
||||||
).map(([from, to]) =>
|
).map(([from, to]) =>
|
||||||
createRoute({
|
createRoute({
|
||||||
@@ -461,7 +486,6 @@ function requirePerm(perm: Permission) {
|
|||||||
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
const SETUP_TABS: { to: string; perm: Permission }[] = [
|
||||||
{ to: "/setup", perm: "site:update" },
|
{ to: "/setup", perm: "site:update" },
|
||||||
{ to: "/setup/tariff", perm: "tariff:read" },
|
{ to: "/setup/tariff", perm: "tariff:read" },
|
||||||
{ to: "/setup/subscriptions", perm: "subscription:read" },
|
|
||||||
{ to: "/setup/site", perm: "site:read" },
|
{ to: "/setup/site", perm: "site:read" },
|
||||||
{ to: "/setup/users", perm: "user:read" },
|
{ to: "/setup/users", perm: "user:read" },
|
||||||
{ to: "/setup/roles", perm: "role:read" },
|
{ to: "/setup/roles", perm: "role:read" },
|
||||||
@@ -496,27 +520,42 @@ const tariffRoute = createRoute({
|
|||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
component: () => <TariffComposer />,
|
component: () => <TariffComposer />,
|
||||||
});
|
});
|
||||||
const tariffLabRoute = createRoute({
|
|
||||||
getParentRoute: () => setupRoute,
|
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
|
||||||
path: "tariff-lab",
|
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
|
||||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
|
||||||
component: () => <TariffLab />,
|
|
||||||
});
|
|
||||||
const subscriptionsRoute = createRoute({
|
const subscriptionsRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "subscriptions",
|
path: "/subscriptions",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
component: SubscriptionsLayout,
|
||||||
|
});
|
||||||
|
// Index tab = the subscriber catalog at /subscriptions exactly. A user lacking
|
||||||
|
// subscription:read is redirected to the first sub-tab they CAN see (or the booth).
|
||||||
|
const subscriptionsIndexRoute = createRoute({
|
||||||
|
getParentRoute: () => subscriptionsRoute,
|
||||||
|
path: "/",
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (can(context.user, "subscription:read")) return;
|
||||||
|
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
|
||||||
|
if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
|
||||||
|
throw redirect({ to: "/booth" });
|
||||||
|
},
|
||||||
component: function SubscriptionsRoute() {
|
component: function SubscriptionsRoute() {
|
||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
return <SubscriptionManager user={user} />;
|
return <SubscriptionManager user={user} />;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const subscriptionPlansRoute = createRoute({
|
const subscriptionPlansRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => subscriptionsRoute,
|
||||||
path: "plans",
|
path: "plans",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||||
component: () => <SubscriptionPlansManager />,
|
component: () => <SubscriptionPlansManager />,
|
||||||
});
|
});
|
||||||
|
const tariffLabRoute = createRoute({
|
||||||
|
getParentRoute: () => subscriptionsRoute,
|
||||||
|
path: "tariff-lab",
|
||||||
|
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||||
|
component: () => <TariffLab />,
|
||||||
|
});
|
||||||
const siteRoute = createRoute({
|
const siteRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "site",
|
path: "site",
|
||||||
@@ -569,12 +608,14 @@ const routeTree = rootRoute.addChildren([
|
|||||||
boothRoute,
|
boothRoute,
|
||||||
...legacyRedirects,
|
...legacyRedirects,
|
||||||
shiftRoute,
|
shiftRoute,
|
||||||
|
subscriptionsRoute.addChildren([
|
||||||
|
subscriptionsIndexRoute,
|
||||||
|
subscriptionPlansRoute,
|
||||||
|
tariffLabRoute,
|
||||||
|
]),
|
||||||
setupRoute.addChildren([
|
setupRoute.addChildren([
|
||||||
setupDevicesRoute,
|
setupDevicesRoute,
|
||||||
tariffRoute,
|
tariffRoute,
|
||||||
tariffLabRoute,
|
|
||||||
subscriptionsRoute,
|
|
||||||
subscriptionPlansRoute,
|
|
||||||
siteRoute,
|
siteRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
rolesRoute,
|
rolesRoute,
|
||||||
|
|||||||
+12
@@ -1268,3 +1268,15 @@ before signing the irreversible Z-report. Opening stays immediate. (3) Fixed dar
|
|||||||
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
|
<select> popups rendering WHITE on WebKitGTK (Tauri Linux) via color-scheme + explicit option colours.
|
||||||
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
|
Verified the split on a read-only DB copy (tickets 0, subs 10,200 = 10,000 sale + 200 out-of-window,
|
||||||
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".
|
reconciles). build+lint 14/14, i18n parity (sq+en). See [[shift]] "Takings split by source".
|
||||||
|
|
||||||
|
## [2026-06-21] feat | Promote Subscriptions to a top-level section with its own tabs
|
||||||
|
Moved Subscriptions out of /setup into a standalone /subscriptions section with a header nav entry
|
||||||
|
(Kabina · Turni · Abonimet · Konfigurimi) and its own tab bar: Abonimet (/subscriptions), Planet
|
||||||
|
(/subscriptions/plans), Lab Tarife (/subscriptions/tariff-lab). Removed those three tabs from the Setup
|
||||||
|
layout (Setup now: Pajisjet · Tarifa · Park · Përdoruesit · Rolet · Turnet · Loget). Tabs are
|
||||||
|
permission-gated (subscription:read / subscription:plan / tariff:read), so an operator with only
|
||||||
|
subscription:read sees just Abonimet; the index redirects to the first allowed sub-tab otherwise.
|
||||||
|
Legacy /setup/subscriptions, /setup/plans, /setup/tariff-lab redirect to the new paths; the old
|
||||||
|
/subscriptions→/setup redirect was removed (it's a real route now). Verified at runtime via Playwright
|
||||||
|
(header order, the 3 sub-tabs, Setup no longer shows them, /setup/subscriptions redirects). The Tariff
|
||||||
|
COMPOSER stays in Setup; only the Tariff LAB simulator moved. build+lint 14/14.
|
||||||
|
|||||||
Reference in New Issue
Block a user